返回

Spring Boot WebSocket 403 Forbidden 错误排查与解决

java

Spring Boot WebSocket 连接 403 Forbidden 问题排查与解决

连接 WebSocket 时遇到 403 Forbidden 错误, 挺让人头疼的。 这篇文章就来聊聊这个问题,帮你分析原因,并提供解决方案。

一、 问题

在集成了 Spring Security 和 JWT 的应用中,尝试建立 WebSocket 连接时,遇到了 403 Forbidden 错误。即使在 Spring Security 过滤器链中对 WebSocket 连接端点设置了 permitAll,还是会报这个错。

二、 问题原因分析

出现这个问题, 通常有以下几种原因:

  1. Spring Security 对 WebSocket 的认证方式不同: 与处理 HTTP 请求不同,Spring Security 对 WebSocket 的认证有其特殊性。直接在请求头里加 Authorization,不一定能直接用。
  2. CSRF 防护: Spring Security 默认开启 CSRF 防护, 这可能会阻止 WebSocket 连接。
  3. CORS 配置问题: 虽然设置了 Spring Security 的 CORS 配置允许客户端来源,但 WebSocket 的连接建立(握手)阶段,仍可能因为跨域问题被拦截。
  4. JWT 过滤器处理: 在JWT认证的场景下, 即使把token放到HTTP握手请求的头部,JWT filter 可能没有正确处理。
  5. 消息级别的安全控制需求不清晰: 需要连接时不做认证(所有人可连),只在发消息时才进行用户认证, 这时如何设计合适的安全方案也是要仔细考量的。

三、解决方案

针对上述原因,可以尝试以下几种方案:

1. 关闭 CSRF 防护 (不推荐用于生产环境)

  • 原理: 暂时禁用 CSRF 防护,排除是否是 CSRF 导致的问题。
  • 操作:SecurityFilterChain 配置中禁用 CSRF:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
        .cors(Customizer.withDefaults())
        .csrf(AbstractHttpConfigurer::disable) // 禁用 CSRF
        // ... 其他配置
        ;
    return httpSecurity.build();
}
  • 注意: 禁用 CSRF 会有安全风险, 尤其是在生产环境中, 请谨慎使用。

2. 配置 WebSocket 的 CSRF 忽略

  • 原理: 只对 WebSocket 相关的请求禁用 CSRF,其他请求仍然受保护。
  • 操作:SecurityFilterChain配置中添加针对 WebSocket 的 CSRF 忽略:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
        .cors(Customizer.withDefaults())
         .csrf(csrf -> csrf
            .ignoringRequestMatchers("/ws/**") // 忽略 WebSocket 路径
            )
        // ... 其他配置
        ;
      return httpSecurity.build();

}

3. 明确 CORS 配置

  • 原理 确认 WebSocket 相关的 origin 设置无误,确保跨域访问被许可。

  • 检查 WebSocketConfig: 确保setAllowedOriginPatterns配置正确

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/ws")
            .setAllowedOriginPatterns("http://localhost:49322") // 确保此处origin正确.
            .withSockJS();
}

4. 使用 ChannelInterceptor 处理认证 (推荐)

  • 原理: 使用 Spring 提供的 ChannelInterceptor 接口,在消息发送到 broker 前进行拦截,从而进行认证和授权。

  • 步骤:

    1. 创建 ChannelInterceptor 实现类:
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import com.example.Dormly.JwtAuthFilter; // 你自己的JWT处理filter
import java.util.List;
import java.util.ArrayList;


public class AuthChannelInterceptor implements ChannelInterceptor {
  private final JwtAuthFilter jwtAuthFilter; //注入JWTFilter.

  public AuthChannelInterceptor (JwtAuthFilter jwtAuthFilter){
      this.jwtAuthFilter = jwtAuthFilter;

  }

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

        if (StompCommand.CONNECT.equals(accessor.getCommand())) {
          List<String> authorization = accessor.getNativeHeader("Authorization");

              // 假设从 headers 中读取 token (客户端发送请求时,将token放此处).
              String token = authorization.get(0).split(" ")[1];

            if (token != null) {
                 // 解析 token 和 提取用户的权限等逻辑 (利用已有的jwtAuthFilter里的方法)

                String userEmail = jwtAuthFilter.fetchEmail(token);
                //这里你需要去检查email,如果email是存在的用户,那你就创建一个Authentication Token.

                  List<SimpleGrantedAuthority> authorities = new ArrayList<>(); // 示例权限.

                Authentication authentication = new UsernamePasswordAuthenticationToken(userEmail,null,authorities);
                 // 认证成功后,设置到 SecurityContext
                SecurityContextHolder.getContext().setAuthentication(authentication);
                accessor.setUser(authentication);


            }
        }
        return message;
    }
}
2.  **配置 `ChannelInterceptor`:**  在 `WebSocketConfig` 中添加:
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import com.example.Dormly.JwtAuthFilter; //JWT Filter 的具体路径
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
     private final JwtAuthFilter jwtAuthFilter; //注入
    public WebSocketConfig(JwtAuthFilter jwtAuthFilter) {

     this.jwtAuthFilter = jwtAuthFilter;

    }
       /// this method initiates the web socket connection, when a client wants to upgrade their
    /// protocol from HTTP to WebSocket
        /// . we also define the servers that can make initiate a websocket connection
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {

        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("http://localhost:49322")
                .withSockJS();
    }

    /// we define the prefix of the placeholder in the url as 'app' in which this will be binded to the
    /// MessageMapping annotation methods, similar to how requestMapping routed the endpoint to the specific method
    /// the message broker is used to define the endpoint in which a user will be subscribed to
    /// we set the user as the prefix
    ///
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/user");
        registry.setApplicationDestinationPrefixes("/app");
    }
      @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new AuthChannelInterceptor(jwtAuthFilter)); // 注册拦截器
    }
}

  • 客户端修改 前端代码中,在建立Stomp连接时带上JWT token:
  connect(){
    console.log("connecting to websocket...")
    const headers = { "Authorization": "Bearer " + this.token };
    console.log("WebSocket headers:", headers);

    let ws = new SockJS(this.brokerURL)
    this.stompClient = Stomp.over(ws)
    this.stompClient.connect({"Authorization" : `Bearer ${this.token}`}, () =>{
      console.log("WebSocket connected");

      // ... 其余连接成功的逻辑
  }

5. 控制消息级别的安全

要实现连接时不认证,只有发送消息到/app/**才认证,结合上面ChannelInterceptor 的处理方式,可按需调整 preSend方法.

  • 思路:ChannelInterceptorpreSend方法里, 判断 StompCommand,对 SEND 命令, 并且目标地址是以 /app 开头的, 才进行 JWT 认证.
   @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

        if (StompCommand.CONNECT.equals(accessor.getCommand())) {
              //CONNECT请求处理,如果希望首次连接就需要认证,可以在这里处理。

        }
        else if (StompCommand.SEND.equals(accessor.getCommand())) {
              // 判断 destination 是否以 /app 开头

            if (accessor.getDestination().startsWith("/app")) {
                  // 从header提取 token 等逻辑 (类似上面).

              List<String> authorization = accessor.getNativeHeader("Authorization");

            // 获取token 类似上方代码。

                 if (token != null) {
                     // 解析token 设置 Authentication, 同上。
                      Authentication authentication = //....
                       SecurityContextHolder.getContext().setAuthentication(authentication);
                      accessor.setUser(authentication); //重要

                   }else{
                       // 没token, 可以选择抛出异常 或者 直接 return null; 这样消息就不会发送成功。
                        throw new AccessDeniedException("No Token Provided");
                        //return null; //直接丢弃

                      }

                }
        }
        return message;
    }

这样修改以后, 你就实现了 WebSocket 连接允许匿名访问, 但是发送到 /app 开头的消息地址才进行认证的需求。

总结

解决 Spring Boot WebSocket 403 Forbidden 问题,需要综合考虑 Spring Security、CORS、CSRF 以及 WebSocket 自身的认证机制。 使用ChannelInterceptor是一个很灵活的方式, 推荐大家采用这种方式. 通过灵活配置ChannelInterceptor,还能细粒度地控制消息级别的安全策略,更好地满足不同场景的需求。