Spring Security配置STOMP权限时无权限订阅抛错断连的优雅处理方案
问题根因
当前触发断连的核心原因是Spring对STOMP消息的默认异常处理机制:当clientInboundChannel抛出AccessDeniedException时,默认的StompSubProtocolHandler会直接向客户端发送STOMP ERROR帧,随后关闭底层WebSocket连接。这套默认逻辑是面向连接建立、消息发送场景设计的,并没有针对SUBSCRIBE订阅请求做差异化处理,因此会出现“订阅无权限直接断连”的问题。
优雅拒绝无权限订阅的实现方案
核心逻辑是保留现有Spring Security的权限校验逻辑不做改动,仅覆盖订阅请求场景下的异常处理流程,在捕获到订阅类权限异常时,主动给客户端发送非断连的错误通知,不再触发默认的连接关闭逻辑。
第一步:在入站通道拦截器中捕获订阅类权限异常
在现有configureClientInboundChannel配置中新增拦截逻辑,利用ChannelInterceptor的afterCompletion方法(消息处理抛出异常时会自动触发)定向捕获SUBSCRIBE命令对应的权限异常,标记这类异常不需要触发默认的断连流程:
@Override public void configureClientInboundChannel(ChannelRegistration registration) { registration.interceptors( // 保留原有CONNECT阶段的token认证逻辑 new ChannelInterceptor() { @Override public Message<?> preSend(Message<?> message, MessageChannel channel) { StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); assert accessor != null; if (StompCommand.CONNECT.equals(accessor.getCommand()) && Objects.nonNull(accessor.getHeader("token"))) { Authentication user = jwtTokenProvider.getAuthentication((String) accessor.getHeader("token")); accessor.setUser(user); } return message; } }, // 新增:定向捕获订阅阶段的权限异常 new ChannelInterceptor() { @Override public void afterCompletion(Message<?> message, MessageChannel channel, Exception ex) { StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); // 仅处理SUBSCRIBE命令触发的权限拒绝异常,其他场景走默认逻辑 if (ex instanceof AccessDeniedException && accessor != null && StompCommand.SUBSCRIBE.equals(accessor.getCommand())) { // 在会话属性中标记该异常不触发连接关闭 SimpMessageHeaderAccessor.getSessionAttributes(accessor.getSessionId()) .put("skipCloseOnSubscribeDenied", true); } } } ); }
第二步:覆盖STOMP协议处理器的默认异常处理逻辑
自定义StompSubProtocolHandler,重写异常处理方法,对标记为订阅权限拒绝的场景,仅向客户端发送普通的错误通知消息,不调用默认的断连逻辑:
@Configuration public class WebSocketCustomConfig { @Autowired private SimpMessagingTemplate simpMessagingTemplate; @Bean public StompSubProtocolHandler customStompSubProtocolHandler() { return new StompSubProtocolHandler() { @Override protected void handleErrorMessage(WebSocketSession session, Message<?> message) { StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); // 读取会话中标记的订阅权限拒绝标识 Map<String, Object> sessionAttrs = session.getAttributes(); boolean isSubscribeDenied = (boolean) sessionAttrs.getOrDefault("skipCloseOnSubscribeDenied", false); if (isSubscribeDenied && accessor != null) { try { // 向客户端专属的错误队列发送订阅失败通知,不关闭连接 simpMessagingTemplate.convertAndSendToUser( accessor.getUser().getName(), "/queue/errors", "无权限订阅主题:" + accessor.getDestination() ); } catch (Exception ignored) { // 发送通知失败不影响主连接 } finally { // 清除标记,避免后续异常误触发逻辑 sessionAttrs.remove("skipCloseOnSubscribeDenied"); } // 不调用父类默认的异常处理(会发送ERROR帧并关闭连接) return; } // 其他场景(如CONNECT认证失败、发送消息权限不足)走默认逻辑 super.handleErrorMessage(session, message); } }; } }
注意事项
- 原有
configureInbound中的权限规则完全不需要改动,Spring Security的权限校验逻辑会正常执行,仅拦截异常的传播路径,不会降低权限安全性 - 必须严格区分异常场景:CONNECT阶段认证失败、消息发送无权限的场景,仍然保留默认的断连逻辑,避免非法连接长期占用服务端资源
- 不要直接在拦截器中吞掉异常不给客户端任何反馈,否则客户端会一直处于订阅等待状态,体验很差;通过用户专属队列发送明确的失败提示,客户端可以基于提示做对应的交互
内容的提问来源于stack exchange,提问作者Tom Haines
相关产品推荐
相关产品推荐

