Quarkus并发线程调用受保护REST方法时的安全上下文传递方案与原理疑问
我来帮你梳理这个Quarkus JWT异步调用的问题,先解释背后的原因,再给你可行的最佳实践方案:
问题根源解析
你遇到的问题本质是Quarkus的上下文绑定机制和安全注解的拦截范围导致的:
- 上下文未传递到异步线程:Quarkus的
RequestScoped上下文、SecurityIdentity(包含JWT身份信息)都是绑定在当前请求线程上的。而你用的CompletableFuture.supplyAsync()默认会使用JDK的ForkJoinPool线程池,这些线程没有继承原请求的上下文,所以调用带@RolesAllowed/@PermissionsAllowed的方法时,要么触发ContextNotActiveException(请求上下文未激活),要么激活后没有有效身份导致401。 - 安全注解是方法级拦截: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 CompletableFuturethievesCountFuture = getTreasureCount();
72 CompletableFuturealibabaCountFuture = 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

