Spring Boot WebSocket 403 Forbidden 错误排查与解决
2025-03-20 17:21:11
Spring Boot WebSocket 连接 403 Forbidden 问题排查与解决
连接 WebSocket 时遇到 403 Forbidden 错误, 挺让人头疼的。 这篇文章就来聊聊这个问题,帮你分析原因,并提供解决方案。
一、 问题
在集成了 Spring Security 和 JWT 的应用中,尝试建立 WebSocket 连接时,遇到了 403 Forbidden 错误。即使在 Spring Security 过滤器链中对 WebSocket 连接端点设置了 permitAll
,还是会报这个错。
二、 问题原因分析
出现这个问题, 通常有以下几种原因:
- Spring Security 对 WebSocket 的认证方式不同: 与处理 HTTP 请求不同,Spring Security 对 WebSocket 的认证有其特殊性。直接在请求头里加
Authorization
,不一定能直接用。 - CSRF 防护: Spring Security 默认开启 CSRF 防护, 这可能会阻止 WebSocket 连接。
- CORS 配置问题: 虽然设置了 Spring Security 的 CORS 配置允许客户端来源,但 WebSocket 的连接建立(握手)阶段,仍可能因为跨域问题被拦截。
- JWT 过滤器处理: 在JWT认证的场景下, 即使把token放到HTTP握手请求的头部,JWT filter 可能没有正确处理。
- 消息级别的安全控制需求不清晰: 需要连接时不做认证(所有人可连),只在发消息时才进行用户认证, 这时如何设计合适的安全方案也是要仔细考量的。
三、解决方案
针对上述原因,可以尝试以下几种方案:
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 前进行拦截,从而进行认证和授权。 -
步骤:
- 创建
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
方法.
- 思路: 在
ChannelInterceptor
的preSend
方法里, 判断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
,还能细粒度地控制消息级别的安全策略,更好地满足不同场景的需求。