使用@Async与DelegatingSecurityContextAsyncTaskExecutor仍遇403权限禁止
我正在开发一个Spring Boot应用,采用自定义X-API-KEY认证实现API安全,部分操作使用方法级异步执行。调用安全端点/api/payments/intents/{id}/confirm确认支付意图时,控制器方法返回CompletableFuture,内部调用标注@Async的服务方法,该方法使用DelegatingSecurityContextAsyncTaskExecutor保留安全上下文,但始终收到403 Forbidden错误,异步流程中认证主体丢失。
错误信息
{ "timestamp": "2025-04-22T01:33:05.355+00:00", "status": 403, "error": "Forbidden", "path": "/api/payments/intents/pi_bb6021bb-e32e-4afb-b80d-e8d38b9b5bc5/confirm" }
控制器代码
@PatchMapping("/api/payments/intents/{id}/confirm") public CompletableFuture<EntityResponse<PaymentIntentDto>> confirmPaymentIntent( @PathVariable String id, @RequestBody @Valid ConfirmPaymentIntent confirmPaymentIntent, @AuthenticationPrincipal CurrentAuth currentAuth) throws Exception { log.info("Inside the @aa logic: {}", SecurityContextHolder.getContext().getAuthentication()); CompletableFuture<EntityResponse<PaymentIntentDto>> future = paymentCommandService.confirmPayment( new ConfirmPaymentCommand(id, confirmPaymentIntent.getPaymentMethodId(), currentAuth.merchantReferenceNumber())); log.info("Inside the @aa2 logic: {}", SecurityContextHolder.getContext().getAuthentication()); return future; }
异步服务方法代码
@Async("delegatingSecurityContextAsyncTaskExecutor") public CompletableFuture<EntityResponse<PaymentIntentDto>> confirmPayment(final ConfirmPaymentCommand confirmPaymentCommand) { log.info("[Start] Confirming payment intent {}", confirmPaymentCommand.paymentIntentReferenceNumber()); log.info("Inside the @Async logic: {}", SecurityContextHolder.getContext().getAuthentication()); var merchantFuture = CompletableFuture.supplyAsync(getMerchant(confirmPaymentCommand)) .thenCompose(merchantOpt -> fromOptional(merchantOpt, MerchantNotFoundException::new)); var paymentIntentFuture = merchantFuture .thenCompose(merchant -> CompletableFuture.supplyAsync(getPaymentIntent(confirmPaymentCommand, merchant))) .thenCompose(paymentIntentOpt -> fromOptional(paymentIntentOpt, PaymentIntentNotFound::new)); var paymentMethodFuture = merchantFuture .thenCompose(merchant -> CompletableFuture.supplyAsync(getPaymentMethod(confirmPaymentCommand, merchant))) .thenCompose(paymentMethodOpt -> fromOptional(paymentMethodOpt, PaymentMethodNotFound::new)); // Combine paymentIntent and paymentMethod futures return paymentIntentFuture.thenCombineAsync((paymentIntent, paymentMethod) -> { // Validate and process paymentIntent log.info("Inside the @Async2 logic: {}", SecurityContextHolder.getContext().getAuthentication()); paymentIntent.validate(paymentMethod); PaymentStrategy paymentStrategy = paymentMethod.getPaymentStrategy(paymentFacade, paymentMethod.getPaymentGateway().name()); NextAction nextAction = null; try { nextAction = paymentStrategy.nextAction(paymentIntent, paymentMethod, merchantFuture.join()); } catch (Exception e) { throw new RuntimeException(e); } ConfirmedPayment confirmedPayment = paymentIntent.requiresAction(paymentMethod.getReferenceNumber()); PaymentIntent saved = paymentFacade.getPaymentIntentCommandRepository().save(paymentIntent); PaymentIntentDto paymentIntentDto = buildDto(saved, nextAction); log.info("[End] Confirmed payment intent {}", confirmPaymentCommand.paymentIntentReferenceNumber()); return new EntityResponse<>( saved.getReferenceNumber(), PAYMENT_INTENT, paymentIntentDto ); }, delegatingSecurityContextAsyncTaskExecutor); }
异步配置代码
@EnableAsync @Configuration public class ThreadPoolConfiguration { @Bean public InitializingBean initializingBean() { return () -> SecurityContextHolder.setStrategyName( SecurityContextHolder.MODE_INHERITABLETHREADLOCAL); } @Bean public ThreadPoolTaskExecutor confirmPaymentExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(27); executor.setMaxPoolSize(50); executor.setQueueCapacity(100); executor.setThreadNamePrefix("confirm-payment-"); return executor; } @Bean public DelegatingSecurityContextAsyncTaskExecutor delegatingSecurityContextAsyncTaskExecutor(ThreadPoolTaskExecutor confirmPaymentExecutor) { return new DelegatingSecurityContextAsyncTaskExecutor(confirmPaymentExecutor); } }
安全配置代码
@Bean @Order(1) public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception { http .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) .cors(Customizer.withDefaults()) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable) //needed for h2 ) .securityMatcher("/api/**") .authorizeHttpRequests(registry -> registry .requestMatchers("/api/**").hasAnyAuthority("API_ADMIN", "API_MERCHANT") ) .addFilterBefore(new ApiKeyFilter(merchantKeyCommandRepository), UsernamePasswordAuthenticationFilter.class); return http.build(); }
API Key过滤器代码
public class ApiKeyFilter extends OncePerRequestFilter { private static final Logger log = LoggerFactory.getLogger(ApiKeyFilter.class); private final MerchantKeyCommandRepository merchantKeyCommandRepository; public ApiKeyFilter(MerchantKeyCommandRepository merchantKeyCommandRepository) { this.merchantKeyCommandRepository = merchantKeyCommandRepository; } @Override protected void doFilterInternal( final HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain ) throws ServletException, IOException { try { log.info("[Start] Filtering by api key {}",Thread.currentThread().getName()); String apiKey = request.getHeader("X-API-KEY"); if (apiKey == null) { throw new BadCredentialsException("No X-API-KEY header found"); } Optional<MerchantKey> merchantKeyOpt = merchantKeyCommandRepository.findByApiKey(apiKey); if(merchantKeyOpt.isEmpty()){ throw new BadCredentialsException("Invalid API Key"); } else if(!merchantKeyOpt.get().isEnabled()){ throw new MerchantKeyDisabledException(); } final MerchantKey key = merchantKeyOpt.get(); Authentication authentication = new ApiKeyAuthenticationToken( new CurrentAuth( key.getApiKey(), key.getSecretKey(), key.getMerchant().getReferenceNumber() ), key.getMerchantKeyPermissions() .stream() .map(permission -> new SimpleGrantedAuthority(permission.name())) .toList() ); SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(authentication); SecurityContextHolder.setContext(context); filterChain.doFilter(request, response); } catch (BadCredentialsException | MerchantKeyDisabledException exp) { respondWithUnauthorized(request, response, exp); } } }
问题分析与解决方法
核心问题
你配置了DelegatingSecurityContextAsyncTaskExecutor传递安全上下文,但异步方法内部调用CompletableFuture.supplyAsync()时,默认用的是ForkJoinPool.commonPool(),这个线程池没被包装,导致安全上下文传不到子任务里,后续操作因为认证主体丢失触发403权限校验失败。另外,MODE_INHERITABLETHREADLOCAL的设置对ForkJoinPool的复用线程不生效,反而可能和线程池的上下文传递机制冲突。
解决步骤
所有异步子任务强制使用自定义安全上下文线程池
调用CompletableFuture.supplyAsync()时,显式传入delegatingSecurityContextAsyncTaskExecutor,确保所有子线程都能拿到安全上下文:// 修改merchantFuture创建方式 var merchantFuture = CompletableFuture.supplyAsync( getMerchant(confirmPaymentCommand), delegatingSecurityContextAsyncTaskExecutor ).thenCompose(merchantOpt -> fromOptional(merchantOpt, MerchantNotFoundException::new)); // 同理修改paymentIntentFuture和paymentMethodFuture中的supplyAsync调用 var paymentIntentFuture = merchantFuture .thenCompose(merchant -> CompletableFuture.supplyAsync( getPaymentIntent(confirmPaymentCommand, merchant), delegatingSecurityContextAsyncTaskExecutor )) .thenCompose(paymentIntentOpt -> fromOptional(paymentIntentOpt, PaymentIntentNotFound::new)); var paymentMethodFuture = merchantFuture .thenCompose(merchant -> CompletableFuture.supplyAsync( getPaymentMethod(confirmPaymentCommand, merchant), delegatingSecurityContextAsyncTaskExecutor )) .thenCompose(paymentMethodOpt -> fromOptional(paymentMethodOpt, PaymentMethodNotFound::new));移除MODE_INHERITABLETHREADLOCAL配置
既然已经用DelegatingSecurityContextAsyncTaskExecutor传递上下文,这个设置没必要,还可能引发冲突,直接删除:// 删掉ThreadPoolConfiguration中的这段代码 // @Bean // public InitializingBean initializingBean() { // return () -> SecurityContextHolder.setStrategyName( // SecurityContextHolder.MODE_INHERITABLETHREADLOCAL); // }验证上下文传递
查看异步方法内的日志,确认SecurityContextHolder.getContext().getAuthentication()在所有子任务中都不为null,且权限信息正确。
内容的提问来源于stack exchange,提问作者fragilepriCe

