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

使用@Async与DelegatingSecurityContextAsyncTaskExecutor仍遇403权限禁止

Spring Boot异步流程中认证主体丢失导致403 Forbidden问题

我正在开发一个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的复用线程不生效,反而可能和线程池的上下文传递机制冲突。

解决步骤

  1. 所有异步子任务强制使用自定义安全上下文线程池
    调用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));
    
  2. 移除MODE_INHERITABLETHREADLOCAL配置
    既然已经用DelegatingSecurityContextAsyncTaskExecutor传递上下文,这个设置没必要,还可能引发冲突,直接删除:

    // 删掉ThreadPoolConfiguration中的这段代码
    // @Bean
    // public InitializingBean initializingBean() {
    //     return () -> SecurityContextHolder.setStrategyName(
    //             SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
    // }
    
  3. 验证上下文传递
    查看异步方法内的日志,确认SecurityContextHolder.getContext().getAuthentication()在所有子任务中都不为null,且权限信息正确。

内容的提问来源于stack exchange,提问作者fragilepriCe

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.13 09:03:11