Reactive Spring:如何将OAuth2授权码流的AccessToken传入定时任务
我正在构建一个定期从第三方API拉取数据的响应式应用,该API要求通过带PKCE的OAuth 2.0授权码流程完成认证(流程受reCAPTCHA保护)。我希望基于Spring Security OAuth Client实现,而非手动编码授权协议。
核心目标:
- 实现定时任务
MyScheduler#fetchData,在我手动通过浏览器完成第三方登录认证后,任务能正常调用API;认证前任务需阻塞。 - 浏览器端的授权已正常工作:访问
/showData会重定向到厂商登录页,认证完成后可正常展示数据,但定时任务始终被阻塞。
推测问题根源:当前访问令牌仅绑定到浏览器会话的安全上下文,无法在应用全局共享令牌(含刷新令牌)。
注:第三方API提供了定期调用的专用端点,但强制要求使用授权码流程,不支持客户端凭证模式。
当前类结构:
MyController#showData和MyScheduler#fetchData均调用ApiClient#retrieveData,该方法通过Spring响应式WebClient发起API请求。当前WebClient配置如下:
@Configuration @EnableWebFluxSecurity class WebClientConfiguration { @Bean WebClient webClient(ReactiveClientRegistrationRepository clientRegs, ReactiveOAuth2AuthorizedClientService authClientService) { ReactiveOAuth2AuthorizedClientManager authClientManager = new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(clientRegs, authClientService); ServerOAuth2AuthorizedClientExchangeFilterFunction oauth = new ServerOAuth2AuthorizedClientExchangeFilterFunction(authClientManager); oauth.setDefaultOAuth2AuthorizedClient(true); oauth.setDefaultClientRegistrationId("test"); return WebClient.builder() .filter(oauth) .build(); } @Bean public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http, ServerOAuth2AuthorizationRequestResolver resolver) { http.authorizeExchange() .anyExchange() .authenticated() .and() .oauth2Login(auth -> auth.authorizationRequestResolver(resolver)); return http.build(); } @Bean public ServerOAuth2AuthorizationRequestResolver pkceResolver( ReactiveClientRegistrationRepository repo) { DefaultServerOAuth2AuthorizationRequestResolver resolver = new DefaultServerOAuth2AuthorizationRequestResolver(repo); resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce()); return resolver; } }
问题出在AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager默认会关联**用户主体(Principal)**存储令牌,而定时任务运行时没有绑定用户上下文,导致无法获取已授权的客户端实例。要实现全局共享令牌,需要做以下调整:
1. 自定义授权客户端管理器,忽略用户主体
创建一个忽略用户主体的ReactiveOAuth2AuthorizedClientManager,让它始终使用同一个"系统用户"标识来存储和获取令牌,这样定时任务和浏览器端可以共享同一套令牌:
@Bean ReactiveOAuth2AuthorizedClientManager authorizedClientManager( ReactiveClientRegistrationRepository clientRegistrationRepository, ReactiveOAuth2AuthorizedClientService authorizedClientService) { AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager manager = new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager( clientRegistrationRepository, authorizedClientService); // 配置授权客户端提供者,支持授权码和刷新令牌流程 OAuth2AuthorizedClientProvider provider = OAuth2AuthorizedClientProviderBuilder.builder() .authorizationCode() .refreshToken() .build(); manager.setAuthorizedClientProvider(provider); // 自定义上下文解析器,返回固定的用户主体标识 manager.setContextAttributesMapper(context -> { Map<String, Object> attributes = new HashMap<>(); attributes.put(OAuth2AuthorizationContext.PRINCIPAL_NAME_ATTRIBUTE_NAME, "system-scheduler"); return Mono.just(attributes); }); return manager; }
2. 修改WebClient配置,使用自定义授权管理器
将原来的authClientManager替换为上面自定义的实例:
@Bean WebClient webClient(ReactiveClientRegistrationRepository clientRegs, ReactiveOAuth2AuthorizedClientService authClientService, ReactiveOAuth2AuthorizedClientManager authorizedClientManager) { ServerOAuth2AuthorizedClientExchangeFilterFunction oauth = new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager); oauth.setDefaultOAuth2AuthorizedClient(true); oauth.setDefaultClientRegistrationId("test"); return WebClient.builder() .filter(oauth) .build(); }
3. 调整定时任务,等待令牌可用
定时任务运行时需确保能获取到授权令牌,添加响应式等待逻辑,直到用户完成认证:
@Component class MyScheduler { private final ApiClient apiClient; private final ReactiveOAuth2AuthorizedClientService authorizedClientService; private final ReactiveClientRegistrationRepository clientRegs; public MyScheduler(ApiClient apiClient, ReactiveOAuth2AuthorizedClientService authorizedClientService, ReactiveClientRegistrationRepository clientRegs) { this.apiClient = apiClient; this.authorizedClientService = authorizedClientService; this.clientRegs = clientRegs; } @Scheduled(fixedRate = 3600000) // 每小时执行一次 public void fetchData() { // 重复检查令牌是否可用,每10秒重试一次 clientRegs.findByRegistrationId("test") .flatMap(clientReg -> authorizedClientService.loadAuthorizedClient("test", "system-scheduler")) .repeatWhenEmpty(repeat -> repeat.delayElements(Duration.ofSeconds(10))) .flatMap(authorizedClient -> apiClient.retrieveData()) .block(); // 响应式转阻塞,适配定时任务执行逻辑 } }
4. 同步登录后的令牌到系统用户标识
修改OAuth2登录成功处理器,将浏览器认证后的令牌复制到固定的"system-scheduler"标识下,确保定时任务能读取到:
@Bean ServerOAuth2AuthenticationSuccessHandler successHandler( ReactiveOAuth2AuthorizedClientService authorizedClientService) { return (authentication, exchange) -> { OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication; String clientId = oauthToken.getAuthorizedClientRegistrationId(); String originalPrincipal = oauthToken.getName(); // 读取原用户标识下的授权令牌,存储到系统用户标识下 return authorizedClientService.loadAuthorizedClient(clientId, originalPrincipal) .flatMap(authorizedClient -> { OAuth2AuthenticationToken systemAuth = new OAuth2AuthenticationToken( oauthToken.getPrincipal(), oauthToken.getAuthorities(), clientId ); return authorizedClientService.saveAuthorizedClient(authorizedClient, systemAuth); }) .then(ServerResponse.ok().build()) .doOnSuccess(response -> exchange.getResponse().setStatusCode(HttpStatus.OK)); }; } // 更新SecurityWebFilterChain,配置成功处理器 @Bean public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http, ServerOAuth2AuthorizationRequestResolver resolver, ServerOAuth2AuthenticationSuccessHandler successHandler) { http.authorizeExchange() .anyExchange() .authenticated() .and() .oauth2Login(auth -> auth .authorizationRequestResolver(resolver) .authenticationSuccessHandler(successHandler)); return http.build(); }
关键原理说明
- 默认的授权客户端管理器会将令牌与登录用户的Principal绑定,定时任务无用户上下文,无法获取令牌;
- 通过自定义上下文解析器,强制使用固定用户标识存储令牌,实现浏览器登录与定时任务的令牌共享;
- 登录成功后将令牌复制到固定标识下,确保定时任务能读取到有效授权信息。
内容的提问来源于stack exchange,提问作者nasezoll

