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

Quarkus并发线程调用受保护REST方法时的安全上下文传递方案与原理疑问

Quarkus并发线程调用受保护REST方法时的安全上下文传递方案与原理疑问

我来帮你梳理这个Quarkus JWT异步调用的问题,先解释背后的原因,再给你可行的最佳实践方案:

问题根源解析

你遇到的问题本质是Quarkus的上下文绑定机制和安全注解的拦截范围导致的:

  1. 上下文未传递到异步线程:Quarkus的RequestScoped上下文、SecurityIdentity(包含JWT身份信息)都是绑定在当前请求线程上的。而你用的CompletableFuture.supplyAsync()默认会使用JDK的ForkJoinPool线程池,这些线程没有继承原请求的上下文,所以调用带@RolesAllowed/@PermissionsAllowed的方法时,要么触发ContextNotActiveException(请求上下文未激活),要么激活后没有有效身份导致401。
  2. 安全注解是方法级拦截:Quarkus的安全注解不是只在REST入口生效,而是基于CDI拦截器实现的全局方法拦截——只要调用带注解的方法,不管是外部REST请求还是内部方法调用,都会触发权限校验。这就是为什么你在异步线程里调用getTreasureCount()也会触发授权检查的原因。

最佳实践方案

针对你的场景,推荐按优先级尝试以下方案:

1. 改用Quarkus原生Mutiny异步API(最推荐)

Quarkus的Mutiny框架(Uni/Multi)会自动处理请求上下文和安全身份的传递,不需要手动干预。你可以把方法的返回类型从CompletableFuture改成Uni,用Mutiny的链式API替代原生CompletableFuture:

@POST
@Path("/take-treasure")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@PermissionsAllowed("take:thieves-treasure")
public Uni<Map<String, Integer>> takeTreasure(TreasureModel takeTreasure) {
    // 调用受保护方法,上下文自动传递
    return Uni.combine().all()
            .unis(getTreasureCount(), getAliBabasTreasureCount())
            .asTuple()
            .onItem().transform(tuple -> {
                TreasureModel thievesTreasure = tuple.getItem1();
                TreasureModel alibabaTreasure = tuple.getItem2();

                if (thievesTreasure.amount() < takeTreasure.amount()) {
                    throw new IllegalArgumentException("Not enough treasure to take");
                }

                alibabaTreasure = new TreasureModel(alibabaTreasure.owner(), alibabaTreasure.amount() + takeTreasure.amount());
                thievesTreasure = new TreasureModel(thievesTreasure.owner(), thievesTreasure.amount() - takeTreasure.amount());

                redis.set(alibabaTreasure.owner(), alibabaTreasure.amount());
                redis.set(thievesTreasure.owner(), thievesTreasure.amount());
                
                Map<String, Integer> results = new HashMap<>();
                results.put(alibabaTreasure.owner(), alibabaTreasure.amount());
                results.put(thievesTreasure.owner(), thievesTreasure.amount());
                return results;
            });
}

这样修改后,异步调用时上下文会自动传递,不会再出现授权或上下文未激活的问题。

2. 拆分方法,避免内部调用触发安全校验

如果不想切换到Mutiny,可以把对外的REST接口和内部业务方法拆分:对外的方法保留安全注解,内部方法只做业务逻辑,不添加安全注解。这样异步线程调用内部方法时不会触发授权检查:

// 对外REST接口,保留安全注解
@GET
@Path("/thieves-treasure")
@RolesAllowed("treasure-hunter")
public CompletableFuture<TreasureModel> getTreasureCount() {
    return getTreasureCountInternal();
}

// 内部业务方法,无安全注解,供内部异步调用
private CompletableFuture<TreasureModel> getTreasureCountInternal() {
    return getTreasure("thieves-treasure", 1000);
}

// 同理处理alibaba的方法
@GET
@Path("/alibaba-treasure")
@PermissionsAllowed("see:alibaba-treasure")
public CompletableFuture<TreasureModel> getAliBabasTreasureCount() {
    return getAliBabasTreasureCountInternal();
}

private CompletableFuture<TreasureModel> getAliBabasTreasureCountInternal() {
    return getTreasure("alibaba-treasure", 0);
}

// 异步方法里调用内部无注解方法
@POST
@Path("/take-treasure")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@PermissionsAllowed("take:thieves-treasure")
public CompletableFuture<Map<String, Integer>> takeTreasure(TreasureModel takeTreasure) {
    return CompletableFuture.supplyAsync(() -> {
        CompletableFuture<TreasureModel> thievesCountFuture = getTreasureCountInternal();
        CompletableFuture<TreasureModel> alibabaCountFuture = getAliBabasTreasureCountInternal();
        
        // 后续逻辑不变
        CompletableFuture<Void> combinedFuture = CompletableFuture.allOf(thievesCountFuture, alibabaCountFuture);
        combinedFuture.join();
        
        // ... 剩余业务逻辑
    });
}

3. 手动传递上下文(不推荐,繁琐易出错)

如果必须使用原生CompletableFuture,可以手动捕获当前的SecurityIdentity和RequestContext,在异步线程中激活上下文并设置身份:

@Inject
SecurityIdentity securityIdentity;
@Inject
RequestContext requestContext;

@POST
@Path("/take-treasure")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@PermissionsAllowed("take:thieves-treasure")
public CompletableFuture<Map<String, Integer>> takeTreasure(TreasureModel takeTreasure) {
    // 捕获当前上下文和身份
    SecurityIdentity currentIdentity = securityIdentity;
    RequestContext currentRequestContext = requestContext;
    
    return CompletableFuture.supplyAsync(() -> {
        // 激活请求上下文
        try (RequestContext.AutoCloseableContext ignored = currentRequestContext.activate()) {
            // 设置当前线程的安全身份
            SecurityIdentity.setCurrent(currentIdentity);
            
            // 现在可以安全调用受保护方法
            CompletableFuture<TreasureModel> thievesCountFuture = getTreasureCount();
            CompletableFuture<TreasureModel> alibabaCountFuture = getAliBabasTreasureCount();
            
            // 后续逻辑不变
            CompletableFuture<Void> combinedFuture = CompletableFuture.allOf(thievesCountFuture, alibabaCountFuture);
            combinedFuture.join();
            
            // ... 剩余业务逻辑
        }
    });
}

你的具体疑问解答

  • 最佳实践:优先选择方案1(改用Mutiny API),这是Quarkus官方推荐的异步编程方式,能自动处理上下文传递,代码更简洁易维护;其次是方案2,拆分方法避免不必要的授权校验;方案3仅作为特殊场景的兜底方案。
  • 为什么内部调用也会触发校验:Quarkus的安全注解是基于CDI拦截器实现的,拦截器会拦截所有方法调用(不管是外部REST请求还是内部方法调用),所以只要方法上有@RolesAllowed/@PermissionsAllowed,任何调用都会触发授权检查,而异步线程没有上下文,就会报错。

你提供的错误信息

2025-03-26 14:38:12,445 ERROR [io.qua.ver.htt.run.QuarkusErrorHandler] (vert.x-eventloop-thread-3) HTTP Request to /api/cave/take-treasure failed, error id: 29f5d35b-e0ee-4b39-b721-a373813eea8a-1

Exception in CaveRestController.java:71
69 return CompletableFuture.supplyAsync(() -> {
70
→ 71 CompletableFuture thievesCountFuture = getTreasureCount();
72 CompletableFuture alibabaCountFuture = getAliBabasTreasureCount();
73 : jakarta.enterprise.context.ContextNotActiveException: RequestScoped context was not active when trying to obtain a bean instance for a client proxy of CLASS bean [class=io.quarkus.vertx.http.runtime.CurrentVertxRequest, id=0_6n6EmChCiiDdd8HelptG_A0AE]
- you can activate the request context for a specific method using the @ActivateRequestContext interceptor binding

你的核心业务代码

@RequestScoped
@Path("/api/cave")
public class CaveRestController {

    @Inject
    private RedisService redis;

    @Inject
    JsonWebToken accessToken;

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    @Path(value="/authorities")
    public Map<String,Object> getPrincipalInfo() {

        Collection<String> authorities = accessToken.getClaimNames();

        Map<String,Object> info = new HashMap<>();
        info.put("name", accessToken.getSubject());
        info.put("authorities", authorities);

        return info;
    }

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    @Path(value="/thieves-treasure")
    @RolesAllowed("treasure-hunter")
    public CompletableFuture<TreasureModel> getTreasureCount() {
        return getTreasure("thieves-treasure", 1000);
    }

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    @Path(value="/alibaba-treasure")
    @PermissionsAllowed("see:alibaba-treasure")
    public CompletableFuture<TreasureModel> getAliBabasTreasureCount() {
        return getTreasure("alibaba-treasure", 0);
    }

    @POST
    @Path(value="/take-treasure")
    @Produces(MediaType.APPLICATION_JSON)
    @Consumes(MediaType.APPLICATION_JSON)
    @PermissionsAllowed("take:thieves-treasure")
    @ActivateRequestContext
    public CompletableFuture<Map<String, Integer>> takeTreasure(TreasureModel takeTreasure) {
        return CompletableFuture.supplyAsync(() -> {

            CompletableFuture<TreasureModel> thievesCountFuture = getTreasureCount(); // ERROR!!!
            CompletableFuture<TreasureModel> alibabaCountFuture = getAliBabasTreasureCount();

            // Wait for both to complete and retrieve results
            CompletableFuture<Void> combinedFuture = CompletableFuture.allOf(thievesCountFuture, alibabaCountFuture);

            // Block until all are done
            combinedFuture.join();

            TreasureModel thievesTreasure = null;
            TreasureModel alibabaTreasure = null;
            try {
                thievesTreasure = thievesCountFuture.get();
                alibabaTreasure = alibabaCountFuture.get();
            } catch (InterruptedException | ExecutionException e) {
                throw new RuntimeException(e);
            }
            if (thievesTreasure.amount() < takeTreasure.amount()) {
                throw new IllegalArgumentException("Not enough treasure to take");
            }

            alibabaTreasure = new TreasureModel(alibabaTreasure.owner(), alibabaTreasure.amount() + takeTreasure.amount());
            thievesTreasure = new TreasureModel(thievesTreasure.owner(), thievesTreasure.amount() - takeTreasure.amount());

            redis.set(alibabaTreasure.owner(), alibabaTreasure.amount());
            redis.set(thievesTreasure.owner(), thievesTreasure.amount());
            Map<String, Integer> results = new HashMap<>();
            results.put(alibabaTreasure.owner(), alibabaTreasure.amount());
            results.put(thievesTreasure.owner(), thievesTreasure.amount());
            return results;
        });
    }


    private CompletableFuture<TreasureModel> getTreasure(String key, Integer initialValue) {
        return CompletableFuture.supplyAsync(() -> {
            try {
                return redis.get(key)
                        .thenApply((value) -> {
                            if (value.isEmpty()) {
                                redis.set(key, initialValue);
                            }
                            return value.orElse(initialValue);
                        }).thenApply(amount -> new TreasureModel(key, amount)).get();
            } catch (InterruptedException | ExecutionException e) {
                throw new RuntimeException(e);
            }
        });
    }

}

你的JWT身份增强器代码

@ApplicationScoped
public class CustomJWTIdentityAugmentor implements SecurityIdentityAugmentor {

    @ConfigProperty(name = "app.config.server.auth.auth0.custom-jwt-namespace.roles")
    private String customRolesNamespace;
    @ConfigProperty(name = "app.config.server.auth.auth0.custom-jwt-namespace.permissions")
    private String customPermissionsNamespace;

    @Override
    public Uni<SecurityIdentity> augment(SecurityIdentity identity, AuthenticationRequestContext context) {
        return context.runBlocking(build(identity));
    }

    private Supplier<SecurityIdentity> build(SecurityIdentity identity) {
        if(identity.isAnonymous()) {
            return () -> identity;
        } else {
            QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(identity);
            Object principal = identity.getPrincipal();
            if (principal instanceof JsonWebToken accessToken) {

                Set<String> customRoles = convertClaimToSet(accessToken.getClaim(customRolesNamespace));
                Set<String> permissions = convertClaimToSet(accessToken.getClaim(customPermissionsNamespace));

                if (!customRoles.isEmpty()) {
                    builder.addRoles(customRoles);
                }
                if (!permissions.isEmpty()) {
                    permissions.forEach(permission -> System.out.println("Permission: " + permission));
                    builder.addPermissionsAsString(permissions);
                }
            }

            return builder::build;
        }
    }

    private Set<String> convertClaimToSet(Object claimValue) {
        Set<String> result = new HashSet<>();
        if (claimValue instanceof JsonArray jsonArray) {
            for (JsonValue jv : jsonArray) {
                if (jv.getValueType() == JsonValue.ValueType.STRING) {
                    result.add(((JsonString) jv).getString());
                }
            }
        } else if (claimValue instanceof Iterable<?> iterable) {
            for (Object item : iterable) {
                if (item instanceof String) {
                    result.add((String) item);
                }
            }
        }
        return result;
    }

}

备注:内容来源于stack exchange,提问作者John Manko

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.04.13 19:49:26