You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.19 14:05:22