Spring Security 5中同步API调用的OAuth2令牌刷新问题排查
问题背景与现象
我们基于Spring Boot 2.4.6搭建了Spring Gateway,通过Spring Security 5 + Webflux响应式模型实现OAuth2认证,IDP采用Keycloak。当前遇到的问题:
- 已认证的前端在AccessToken过期后发起多个同步API请求时,仅最后一个请求能完成Keycloak重新认证,其余请求直接丢失,导致前端抛出异常;
- 但刷新UI页面时,令牌可以无缝刷新,无异常。
提供的核心配置代码如下:
SecurityWebFilterChain 配置
/* * Enable oauth2 authentication on all requests, but use our custom * RegistrationRepository */ .and() .oauth2Login() .authenticationSuccessHandler(new AuthSuccessHandler(requestCache)) // handle success login .authenticationFailureHandler((exchange, excep) -> { LOGGER.debug("Authentication failure: {}", excep.getMessage()); return Mono.empty(); }) .clientRegistrationRepository(clientReg); // Add our custom filter to the security chain final KeycloakClientLoginFilter keyclockLogin = new KeycloakClientLoginFilter( clientReg, redirectStrategy, requestCache, authClientService); clientReg.setKeycloakClientLoginFilter(keyclockLogin); http.addFilterBefore(keyclockLogin, SecurityWebFiltersOrder.LOGIN_PAGE_GENERATING); return http.build();
AuthSuccessHandler 实现
private class AuthSuccessHandler implements ServerAuthenticationSuccessHandler { private final ServerRequestCache requestCache; private final URI defaultLocation = URI.create("/login"); private AuthSuccessHandler(ServerRequestCache requestCache) { this.requestCache = requestCache; } @Override public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication) { final ServerWebExchange exchange = webFilterExchange.getExchange(); return requestCache.getRedirectUri(exchange) .defaultIfEmpty(defaultLocation) .flatMap(location -> { LOGGER.debug("Authentication success. Redirecting request to {}", location.toASCIIString()); return redirectStrategy.sendRedirect(exchange, location); }); } }
KeycloakClientLoginFilter 核心逻辑
final ClientRegistration keycloakReg = clientReg.getRegistration(tenantId, appId); if (!isError && loginRedirects.containsKey(keycloakReg.getRegistrationId())) { final String contextPath = exchange.getRequest().getPath().contextPath().value(); final URI redirect = URI.create(contextPath + loginRedirects.get(keycloakReg.getRegistrationId())); if (LOGGER.isDebugEnabled()) { LOGGER.debug("About to redirect to keycloak; for method {}, tenant={}", exchange.getRequest().getMethod(), tenantId); } // Save the request so the URL can be retreived on successful login return requestCache.saveRequest(exchange) .then(redirectStrategy.sendRedirect(exchange, redirect)); }
原因分析
1. CookieServerRequestCache 的单请求覆盖特性
默认的CookieServerRequestCache(当前使用的请求缓存实现)在保存请求时,会用新请求覆盖同一会话下已缓存的请求。当多个同步请求同时触发认证跳转时,只有最后一个请求的信息会被留存,前面的请求缓存被覆盖,认证完成后只能重定向到最后一个请求的地址,其余请求因无缓存记录而丢失。
2. AuthenticationWebFilter 的无等待机制
Webflux响应式模型下,AuthenticationWebFilter不会对同一会话的多个未认证请求做排队处理,每个请求都会独立触发认证流程。当第一个请求触发Keycloak跳转后,后续请求仍会重复触发跳转,但请求缓存被覆盖,最终只有最后一个请求能被正确恢复。
简易解决方案
1. 扩展请求缓存,支持多请求存储
自定义ServerRequestCache实现,针对同一会话保存多个请求的URI,避免覆盖。示例核心逻辑:
public class MultiRequestCookieServerRequestCache extends CookieServerRequestCache { private static final String CACHED_REQUESTS_COOKIE = "SPRING_SECURITY_CACHED_REQUESTS"; @Override public Mono<Void> saveRequest(ServerWebExchange exchange) { // 从Cookie读取已缓存的请求列表 return getCachedRequests(exchange) .map(requests -> { String currentUri = exchange.getRequest().getURI().toString(); if (!requests.contains(currentUri)) { requests.add(currentUri); } return requests; }) .defaultIfEmpty(new LinkedList<>()) .flatMap(requests -> { // 将更新后的列表写回Cookie ResponseCookie cookie = ResponseCookie.from(CACHED_REQUESTS_COOKIE, String.join(";", requests)) .path(exchange.getRequest().getPath().contextPath().value()) .httpOnly(true) .secure(exchange.getRequest().isSecure()) .build(); exchange.getResponse().getCookies().add(CACHED_REQUESTS_COOKIE, cookie); return Mono.empty(); }); } private Mono<List<String>> getCachedRequests(ServerWebExchange exchange) { return Mono.justOrEmpty(exchange.getRequest().getCookies().getFirst(CACHED_REQUESTS_COOKIE)) .map(cookie -> Arrays.asList(cookie.getValue().split(";"))) .defaultIfEmpty(new LinkedList<>()); } }
2. 修改认证成功处理器,批量处理请求
在AuthSuccessHandler中读取所有缓存的请求URI,认证完成后将待处理请求信息传递给前端,由前端重新发起请求:
@Override public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication) { final ServerWebExchange exchange = webFilterExchange.getExchange(); return getCachedRequests(exchange) .defaultIfEmpty(new LinkedList<>()) .flatMap(requests -> { if (requests.isEmpty()) { return redirectStrategy.sendRedirect(exchange, defaultLocation); } // 重定向到第一个请求地址,其余请求存入Cookie让前端读取 String firstUri = requests.get(0); ResponseCookie pendingRequestsCookie = ResponseCookie.from("PENDING_REQUESTS", String.join(";", requests.subList(1, requests.size()))) .path(exchange.getRequest().getPath().contextPath().value()) .httpOnly(false) // 允许前端读取 .secure(exchange.getRequest().isSecure()) .maxAge(60) // 设置1分钟有效期 .build(); exchange.getResponse().getCookies().add("PENDING_REQUESTS", pendingRequestsCookie); return redirectStrategy.sendRedirect(exchange, URI.create(firstUri)); }) .doFinally(signalType -> { // 清空缓存的请求列表 exchange.getResponse().getCookies().remove(CACHED_REQUESTS_COOKIE); }); }
3. 添加会话级认证锁,避免重复触发认证
在KeycloakClientLoginFilter中添加锁机制,确保同一时间只有一个请求触发Keycloak认证,其余请求等待认证完成后再处理(单实例用本地锁,集群场景替换为Redis分布式锁):
// 在KeycloakClientLoginFilter中定义锁 private final ReentrantLock sessionLock = new ReentrantLock(); @Override public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) { String sessionId = exchange.getRequest().getCookies().getFirst("SESSION").getValue(); // 尝试获取锁,超时则返回401让前端重试 return Mono.fromCallable(() -> sessionLock.tryLock(5, TimeUnit.SECONDS)) .flatMap(locked -> { if (!locked) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } try { // 原有认证逻辑 final ClientRegistration keycloakReg = clientReg.getRegistration(tenantId, appId); if (!isError && loginRedirects.containsKey(keycloakReg.getRegistrationId())) { final String contextPath = exchange.getRequest().getPath().contextPath().value(); final URI redirect = URI.create(contextPath + loginRedirects.get(keycloakReg.getRegistrationId())); return requestCache.saveRequest(exchange) .then(redirectStrategy.sendRedirect(exchange, redirect)); } return chain.filter(exchange); } finally { sessionLock.unlock(); } }); }
内容的提问来源于stack exchange,提问作者Mike at Savient
相关产品推荐
相关产品推荐

