Spring Security STOMP WebSocket JWT认证失败:无法发送消息到ExecutorSubscribableChannel
报错信息
STOMP Error: Failed to send message to ExecutorSubscribableChannel[clientInboundChannel]
环境配置
- Spring Boot 3.x + Spring Security 6.x
- 基于JWT(access+refresh token)实现认证
- 禁用CSRF,采用无状态会话
- React前端通过SockJS/STOMP建立连接
预期与实际行为
- 预期:WebSocket连接通过JWT认证并允许消息交互
- 实际:WebSocket握手成功,但STOMP CONNECT帧被拒绝
当前配置
SecurityConfig.java(HTTP安全配置)
@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http .cors(Customizer.withDefaults()) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(request -> request .requestMatchers("/auth/**").permitAll() .requestMatchers("/ws/**").authenticated() // 可能是问题所在? .anyRequest().authenticated() ) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) .build(); }
WebSocketSecurityConfig.java
@Configuration @EnableWebSocketSecurity public class WebSocketSecurityConfig { @Bean public AuthorizationManager<Message<?>> messageAuthorizationManager() { return MessageMatcherDelegatingAuthorizationManager.builder() .nullDestMatcher().permitAll() // Allow CONNECT frames .simpDestMatchers("/app/**").authenticated() .simpSubscribeDestMatchers("/topic/**", "/user/**").authenticated() .anyMessage().denyAll() .build(); } }
WebSocketConfig.java(基础配置,无拦截器)
@Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") .setAllowedOrigins("http://localhost:5173") .withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker("/topic", "/user"); registry.setApplicationDestinationPrefixes("/app"); } // 尝试过添加拦截器,但未启用 // @Override // public void configureClientInboundChannel(ChannelRegistration registration) { // // Create authorization interceptor with event publisher // AuthorizationChannelInterceptor auths = new AuthorizationChannelInterceptor(messageAuthorizationManager); // SpringAuthorizationEventPublisher publisher = new SpringAuthorizationEventPublisher(applicationContext); // auths.setAuthorizationEventPublisher(publisher); // // registration.interceptors( // new SecurityContextChannelInterceptor(), // Transfers auth context for WebSocket messages // auths // Handles WebSocket message authorization // ); // } }
JWTFilter.java(处理HTTP和WebSocket令牌)
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader = request.getHeader("Authorization"); String token = null; if (authHeader != null && authHeader.startsWith("Bearer ")) { token = authHeader.substring(7); } // 处理WebSocket连接的查询参数令牌 if (token == null && request.getRequestURI().startsWith("/ws") && request.getParameter("access_token") != null) { token = request.getParameter("access_token"); } if (token != null) { // JWT验证逻辑(HTTP请求下正常工作) String username = jwtService.extractUserName(token); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = userDetailsService.loadUserByUsername(username); if (jwtService.validateToken(token, userDetails)) { UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authToken); } } } filterChain.doFilter(request, response); }
AppUserDetails.java
@Override public Collection<? extends GrantedAuthority> getAuthorities() { return Collections.emptyList(); // 这会不会导致问题? } @Override public String getUsername() { return user.getUserName(); // 返回邮箱 }
前端连接代码
const headers = { 'Authorization': `Bearer ${accessToken}` }; stompClient.connect(headers, onConnected, onError);
服务器日志
✅ JWT authentication successful for user: priya@gmail.com (HTTP handshake) ❌ STOMP Error: Failed to send message to ExecutorSubscribableChannel[clientInboundChannel]
已尝试操作
- 修改SecurityConfig,允许/ws/info/**、/ws/*/websocket等路径访问
- 在WebSocketSecurityConfig中为CONNECT帧设置.permitAll()
- 验证JWT令牌提取逻辑,HTTP和WebSocket请求均能正确提取令牌
疑问
- 是否需要在WebSocketConfig中添加特定拦截器,将HTTP握手的认证上下文传递到STOMP消息?
- AppUserDetails.getAuthorities()返回空列表是否可行?Spring Security是否要求WebSocket认证至少有一个权限?
- SecurityConfig中拦截/ws/**是否会阻碍WebSocket认证流程?
- 如何正确分离HTTP和WebSocket认证,避免拦截器影响HTTP登出功能?
解答
问题1:是否需要添加拦截器传递认证上下文?
需要。HTTP握手阶段JWTFilter完成的认证上下文默认不会自动传递到WebSocket的消息处理线程(WebSocket消息在独立线程池运行),必须启用SecurityContextChannelInterceptor来复制认证上下文到STOMP消息处理线程中。
修改WebSocketConfig,启用configureClientInboundChannel方法:
private final ApplicationContext applicationContext; public WebSocketConfig(ApplicationContext applicationContext) { this.applicationContext = applicationContext; } @Override public void configureClientInboundChannel(ChannelRegistration registration) { AuthorizationChannelInterceptor auths = new AuthorizationChannelInterceptor(messageAuthorizationManager()); SpringAuthorizationEventPublisher publisher = new SpringAuthorizationEventPublisher(applicationContext); auths.setAuthorizationEventPublisher(publisher); registration.interceptors( new SecurityContextChannelInterceptor(), // 复制认证上下文到消息线程 auths // 处理STOMP消息授权 ); }
问题2:空权限列表是否可行?
Spring Security允许空权限列表,只要你的授权规则没有要求特定权限。当前你的messageAuthorizationManager只要求authenticated(),所以空列表本身不会导致拒绝。若要彻底排除这个可能性,可以临时添加一个默认权限,比如:
@Override public Collection<? extends GrantedAuthority> getAuthorities() { return Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")); }
问题3:SecurityConfig拦截/ws/**是否有问题?
有问题。SockJS会发起多个HTTP握手请求(如/ws/info、/ws/xxx/websocket),而前端是在STOMP CONNECT帧中携带Authorization头,握手阶段的HTTP请求并未携带令牌,requestMatchers("/ws/**").authenticated()会直接拦截这些请求。
修改SecurityConfig,放开WebSocket握手路径,将认证逻辑移到STOMP消息层面:
.authorizeHttpRequests(request -> request .requestMatchers("/auth/**").permitAll() .requestMatchers("/ws/**", "/ws/info/**", "/ws/*/websocket").permitAll() // 放开握手路径 .anyRequest().authenticated() )
问题4:如何分离HTTP和WebSocket认证?
- HTTP认证:保留现有JWTFilter处理所有非WebSocket的HTTP请求,维持无状态会话逻辑。
- WebSocket认证:
- 放开握手阶段的HTTP路径,避免JWTFilter拦截握手请求。
- 新增自定义ChannelInterceptor,专门处理STOMP CONNECT帧的JWT验证:
@Component public class StompJwtAuthenticationInterceptor implements ChannelInterceptor { private final JwtService jwtService; private final UserDetailsService userDetailsService; public StompJwtAuthenticationInterceptor(JwtService jwtService, UserDetailsService userDetailsService) { this.jwtService = jwtService; this.userDetailsService = userDetailsService; } @Override public Message<?> preSend(Message<?> message, MessageChannel channel) { StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); if (StompCommand.CONNECT.equals(accessor.getCommand())) { List<String> authHeaders = accessor.getNativeHeader("Authorization"); if (authHeaders != null && !authHeaders.isEmpty()) { String authHeader = authHeaders.get(0); if (authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); String username = jwtService.extractUserName(token); if (username != null) { UserDetails userDetails = userDetailsService.loadUserByUsername(username); if (jwtService.validateToken(token, userDetails)) { UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); accessor.setUser(authToken); SecurityContextHolder.getContext().setAuthentication(authToken); } } } } } return message; } }
然后在WebSocketConfig中添加该拦截器:
@Override public void configureClientInboundChannel(ChannelRegistration registration) { AuthorizationChannelInterceptor auths = new AuthorizationChannelInterceptor(messageAuthorizationManager()); SpringAuthorizationEventPublisher publisher = new SpringAuthorizationEventPublisher(applicationContext); auths.setAuthorizationEventPublisher(publisher); registration.interceptors( new StompJwtAuthenticationInterceptor(jwtService, userDetailsService), // 处理STOMP CONNECT帧的JWT认证 new SecurityContextChannelInterceptor(), auths ); }
这样HTTP与WebSocket认证完全分离,JWTFilter仅处理HTTP请求,STOMP认证由专属拦截器负责,不会影响HTTP登出逻辑(登出时只需作废JWT或清除HTTP的SecurityContext,WebSocket连接可由前端主动断开或服务器根据令牌过期处理)。
内容的提问来源于stack exchange,提问作者Ankit Arsh

