Quarkus Mutiny上下文传播与SSE:如何保存订阅时的SecurityIdentity
我正尝试搭建一个基于订阅客户端身份过滤事件的SSE(Server-Sent Events)通道,但订阅时获取的SecurityIdentity无法在事件分发时传播,导致调用channel.send时触发UnauthorizedException。
当前实现中,服务器调用channel.send(event)时没有可注入的SecurityIdentity,assert ! _identity.isAnonymous();断言失败(实际是因为@Authenticated注解被检测到,导致分发失败)。请问如何保存订阅REST请求时的安全身份,以便后续事件分发时使用?
当前代码实现
REST资源类
@GET @Path("stream") @Produces(MediaType.SERVER_SENT_EVENTS) @RestStreamElementType(MediaType.APPLICATION_JSON) public Multi<PassportEvent> stream(@QueryParam("order") Integer orderId) { return _passportService.stream(orderId); }
服务类
@Inject @Channel("passports") Multi<PassportEvent> passportEvents; @Inject SecurityIdentity _identity; @Authenticated public Multi<PassportEvent> stream(Integer orderId) { assert ! _identity.isAnonymous(); return passportEvents .filter(event -> Objects.equals(event.tenantId, getTenantId()) && (orderId == null || Objects.equals(orderId, event.orderId))); }
事件发送服务
@Inject @Channel("passports") Emitter<PassportEvent> passportEvents; // no security identity here createPassport(...) { ... if (passportEvents.hasRequests()) { PassportEvent event = ...; passportEvents.send(event); } } }
问题核心是事件发送时的上下文和订阅请求的上下文完全分离,不能直接依赖注入SecurityIdentity获取订阅者身份。正确思路是在订阅阶段就完成身份校验和事件过滤绑定,而非在发送事件时处理权限逻辑。以下是几种可行方案:
1. 简化逻辑:订阅时完成权限校验与过滤
你当前的@Authenticated注解只需在订阅请求时生效,确保只有认证用户能创建订阅;事件发送端仅负责广播事件,Quarkus的SSE机制会自动将事件推送给每个订阅者已过滤的流。
调整服务类实现,明确权限校验只在订阅时执行:
@Inject @Channel("passports") Multi<PassportEvent> passportEvents; @Inject SecurityIdentity _identity; @Authenticated public Multi<PassportEvent> stream(Integer orderId) { // 订阅阶段校验身份,确保是已认证用户 assert ! _identity.isAnonymous(); String currentTenantId = getTenantId(); // 返回已过滤的流,仅包含当前租户和指定订单的事件 return passportEvents .filter(event -> Objects.equals(event.tenantId, currentTenantId) && (orderId == null || Objects.equals(orderId, event.orderId))); }
这种方式下,事件发送端无需关心任何身份信息,正常发送事件即可,不会触发分发阶段的权限检查。
2. 自定义订阅注册表:绑定订阅者身份
如果需要更细粒度的权限控制,可以创建订阅注册表,在订阅时保存每个订阅者的身份信息,发送事件时基于身份判断是否推送:
首先实现订阅注册表:
@ApplicationScoped public class PassportSubscriptionRegistry { private final Map<String, SecurityIdentity> subscriberIdentities = new ConcurrentHashMap<>(); public void registerSubscriber(String subscriberId, SecurityIdentity identity) { subscriberIdentities.put(subscriberId, identity); } public void unregisterSubscriber(String subscriberId) { subscriberIdentities.remove(subscriberId); } public boolean isAuthorized(String subscriberId, PassportEvent event) { SecurityIdentity identity = subscriberIdentities.get(subscriberId); if (identity == null || identity.isAnonymous()) { return false; } String tenantId = getTenantIdFromIdentity(identity); return Objects.equals(event.tenantId, tenantId); } }
然后在订阅方法中注册并绑定身份:
@Authenticated public Multi<PassportEvent> stream(Integer orderId, @Context SseEventSink sink) { String subscriberId = UUID.randomUUID().toString(); _subscriptionRegistry.registerSubscriber(subscriberId, _identity); // 订阅取消时移除注册信息 sink.onClose(() -> _subscriptionRegistry.unregisterSubscriber(subscriberId)); return passportEvents .filter(event -> _subscriptionRegistry.isAuthorized(subscriberId, event) && (orderId == null || Objects.equals(orderId, event.orderId))); }
该方案适合需要针对单个订阅者做复杂权限判断的场景。
3. 事件嵌入租户标识:发送端无需身份上下文
既然过滤逻辑基于租户ID和订单ID,可直接在事件中嵌入租户标识,订阅者在订阅阶段基于自身租户ID过滤流。事件发送端只需广播带有租户标识的事件,完全不需要关心身份信息,这是最贴合SSE设计思路的方案。
内容的提问来源于stack exchange,提问作者Franck

