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

Spring Security STOMP WebSocket JWT认证失败:无法发送消息到ExecutorSubscribableChannel

Spring Boot 3.x + Spring Security 6.x 下JWT认证的STOMP WebSocket连接失败问题

报错信息

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请求均能正确提取令牌

疑问

  1. 是否需要在WebSocketConfig中添加特定拦截器,将HTTP握手的认证上下文传递到STOMP消息?
  2. AppUserDetails.getAuthorities()返回空列表是否可行?Spring Security是否要求WebSocket认证至少有一个权限?
  3. SecurityConfig中拦截/ws/**是否会阻碍WebSocket认证流程?
  4. 如何正确分离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认证?

  1. HTTP认证:保留现有JWTFilter处理所有非WebSocket的HTTP请求,维持无状态会话逻辑。
  2. 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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.12 22:07:12