☰
SpringBoot 3 WebSocket 握手拦截器实现登录鉴权与安全连接
2026/10/27 15:42:38 网站建设 项目流程

SpringBoot 3 里做 WebSocket,最容易翻车的地方不是连接建不起来,而是连接建起来之后“谁都能连”。我见过太多项目,前端new WebSocket("ws://xxx/ws")一贴,后端@ServerEndpoint一挂,消息能通,测试通过,上线。然后某天发现有人拿个浏览器控制台就能往你推送通道里塞数据,或者更常见的——用户 A 的订单状态推送到了用户 B 的页面上。根子就在于握手阶段没做身份校验,或者说做了但没做对。

这篇内容就围绕一件事展开:在 SpringBoot 3 环境下,把 WebSocket 和拦截器捏到一起,让每一次握手都经过登录态验证,验证不过的直接在握手阶段掐掉,连连接都不给建。涉及的技术点包括 SpringBoot 3 的 Jakarta 命名空间迁移、ServerEndpointExporter的注册机制、HandshakeInterceptor的beforeHandshake与afterHandshake生命周期、以及怎么在拦截器里安全地取到会话中的登录信息。适合已经能跑通基础 WebSocket、但卡在“怎么加鉴权”这一步的开发者,也适合想搞清楚握手拦截器执行时机的人。

先说清楚一个前提:WebSocket 的鉴权跟普通 HTTP 接口的鉴权逻辑不一样。HTTP 请求你可以用过滤器、拦截器、AOP 随便切,因为每次请求都是独立的。WebSocket 是一次握手升级成持久连接,握手那一下是个 HTTP 请求,升级之后就是帧通信了。所以鉴权的窗口只有握手那一瞬间,过了这个村,后面再想校验就得自己在消息体里带 token,那是另一套玩法。握手拦截器就是卡在这个窗口上的钩子,用好了干净利落,用不好就是各种“连接已建立但立刻被关闭”的玄学问题。

1. 为什么握手阶段才是鉴权的唯一正确入口

很多人第一反应是:我在@OnOpen里判断一下 session 里有没有用户不就行了?不行,或者说不够。@OnOpen触发的时候,TCP 连接已经建立、协议已经升级完成,此时你再判断,无非是判断完主动调session.close()。从功能上看好像也达到了“未登录不让用”的效果,但从工程角度看问题一堆。

第一,资源浪费。连接已经建立,服务端分配了 session、占用了连接数,你才去关,高并发场景下这就是白白消耗。第二,语义混乱。客户端收到的是“连接成功后又断开”,前端得写额外的重连逻辑去区分“正常断开”和“鉴权失败断开”,体验很差。第三,也是最关键的——@OnOpen里拿不到原始握手请求的完整信息。你想读 header 里的 token、想读 cookie、想看请求路径上的参数,在@OnOpen的Session对象里都拿不全。而HandshakeInterceptor的beforeHandshake方法签名里直接给你ServerHttpRequest和ServerHttpResponse,header、cookie、URI 参数一应俱全,这才是做鉴权该待的地方。

握手拦截器的beforeHandshake返回false时,握手直接失败,客户端收到的是 HTTP 层面的 403 或连接被拒,根本不会进入 WebSocket 协议。这就是“连门都不让进”和“进门再赶出去”的本质区别。

那为什么标题里强调“拦截器”而不是“过滤器”?因为 WebSocket 的握手拦截是 Spring 的HandshakeInterceptor体系,它跟 MVC 的HandlerInterceptor是两套东西,别搞混。HandshakeInterceptor专门服务于 WebSocket 握手,能拿到ServerHttpRequest,也能往attributes里塞东西供后续@OnOpen使用。这个attributes的传递能力是它比过滤器更合适的原因——过滤器里你没法把解析出来的用户信息优雅地传给 WebSocket 端点。

再补一个容易被忽略的点:SpringBoot 3 用的是 Jakarta EE 9+ 的命名空间,javax.websocket全部换成了jakarta.websocket。如果你从 SpringBoot 2 的项目里抄代码,@ServerEndpoint的 import 大概率是错的,编译能过但运行时不生效,或者直接报类找不到。这个坑我在迁移项目时踩过,排查了半天才发现是包名问题。

2. 环境搭建与依赖的取舍细节

2.1 依赖清单与版本对齐

SpringBoot 3 项目整合 WebSocket,核心依赖就一个:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>

这个 starter 已经把spring-websocket和底层的 WebSocket 实现(默认 Tomcat 的 websocket 支持)带进来了。不需要额外引jakarta.websocket-api,starter 传递依赖里有了,手动再引反而可能版本冲突。

版本上有个细节:SpringBoot 3.x 要求 JDK 17 起步,如果你还在 JDK 8 上折腾,jakarta命名空间和一堆新 API 都用不了,别硬上。我建议直接用 JDK 17 或 21,配合 SpringBoot 3.2.x 这个相对稳定的版本线。SpringBoot 3.0 早期版本在 WebSocket 的自动配置上有过一些小问题,3.1 之后基本稳定。

如果你要用原生的@ServerEndpoint注解方式(而不是 Spring 的WebSocketHandler体系),需要额外注册一个ServerEndpointExporter:

@Configuration public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }

这里有个大坑必须说清楚:ServerEndpointExporter只在内嵌容器(比如 SpringBoot 内置的 Tomcat)下需要手动注册。如果你把应用打成 war 包丢到外部 Tomcat 里跑,这个 Bean 千万别注册,否则会跟容器自己的扫描机制打架,导致端点重复注册或者启动报错。判断标准很简单:main方法启动的就注册,外置容器部署的就删掉。我见过有人本地内嵌跑得好好的,一上测试环境外置部署就炸,就是这个问题。

2.2 端点类的注册方式选择

原生@ServerEndpoint方式下,端点类需要被 Spring 管理才能注入业务 Bean(比如用户服务、token 解析器)。但@ServerEndpoint标注的类默认是由 WebSocket 容器实例化的,不是 Spring 容器,所以直接@Autowired会拿到 null。解决办法是把端点类也注册成 Spring Bean,同时注意每次连接都会 new 一个端点实例——这是 JSR-356 的规范,@ServerEndpoint类是无状态的,每个连接一个实例。

@Component @ServerEndpoint(value = "/ws/chat", interceptors = {AuthHandshakeInterceptor.class}) public class ChatEndpoint { // 每个连接一个实例,成员变量不能存共享状态 }

注意interceptors属性里写的拦截器类,容器会自己去实例化,同样不走 Spring 容器。如果你的拦截器里需要注入 Spring Bean(比如 Redis 操作、JWT 工具),直接在拦截器类里@Autowired是拿不到的。这是个高频翻车点,后面第 4 节专门讲怎么解决。

3. 握手拦截器的完整实现与生命周期拆解

3.1 beforeHandshake 的返回值语义

HandshakeInterceptor接口就两个方法:

public interface HandshakeInterceptor { boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception; void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception); }

beforeHandshake返回true表示放行,握手继续;返回false表示拒绝,握手终止。这里的关键是:返回false之后,afterHandshake仍然会被调用,但exception参数是 null(因为不是异常导致的失败)。所以你不能指望在afterHandshake里区分“正常完成”和“被拒绝”,判断逻辑得放在beforeHandshake里。

attributes这个 Map 是贯穿握手和后续连接的关键载体。你在beforeHandshake里attributes.put("userId", xxx),到了@OnOpen里可以通过session.getUserProperties()或者端点方法的EndpointConfig拿到。这是把鉴权结果传递给业务逻辑的标准姿势,比用 ThreadLocal 靠谱得多——WebSocket 的消息处理线程和握手线程不是同一个,ThreadLocal 会丢。

3.2 从请求中提取登录凭证的几种方式

凭证从哪来,取决于你的登录体系。常见三种:

凭证位置提取方式适用场景注意事项
Cookierequest.getHeaders().getFirst("Cookie")或HttpCookie解析浏览器同源场景跨域时 Cookie 可能不带,需配withCredentials
URL 参数request.getURI().getQuery()解析简单场景、移动端token 暴露在 URL 里,会进日志,安全性弱
Headerrequest.getHeaders().getFirst("Authorization")前后端分离、移动端浏览器原生 WebSocket API 不支持自定义 header

最后一行是个硬约束:浏览器原生的new WebSocket()构造函数不支持自定义请求头。你想在握手时带Authorization: Bearer xxx,原生 API 做不到。这就是为什么很多项目退而求其次用 URL 参数传 token,或者用 Cookie。如果确实需要 header,得用 SockJS 或者 STOMP 客户端,它们底层走 HTTP 可以带 header,但那是另一套复杂度了。

我的实践建议:Web 端用 Cookie(配合 HttpOnly 更安全),移动端或非浏览器客户端用 URL 参数或 header。拦截器里做兼容,按优先级依次尝试。

3.3 一个可直接抄的拦截器实现

@Slf4j public class AuthHandshakeInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) { String token = resolveToken(request); if (token == null || token.isBlank()) { log.warn("WebSocket 握手被拒:缺少凭证, uri={}", request.getURI()); return false; } try { LoginUser user = TokenParser.parse(token); if (user == null) { log.warn("WebSocket 握手被拒:凭证无效"); return false; } attributes.put("userId", user.getUserId()); attributes.put("userName", user.getUserName()); return true; } catch (Exception e) { log.warn("WebSocket 握手被拒:凭证解析异常", e); return false; } } private String resolveToken(ServerHttpRequest request) { // 1. 优先 header String auth = request.getHeaders().getFirst("Authorization"); if (auth != null && auth.startsWith("Bearer ")) { return auth.substring(7); } // 2. 其次 URL 参数 String query = request.getURI().getQuery(); if (query != null) { for (String pair : query.split("&")) { String[] kv = pair.split("=", 2); if (kv.length == 2 && "token".equals(kv[0])) { return kv[1]; } } } // 3. 最后 Cookie List<HttpCookie> cookies = request.getHeaders().get(HttpHeaders.COOKIE) == null ? Collections.emptyList() : HttpCookie.parse(request.getHeaders().getFirst(HttpHeaders.COOKIE)); for (HttpCookie cookie : cookies) { if ("ACCESS_TOKEN".equals(cookie.getName())) { return cookie.getValue(); } } return null; } @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 握手完成后的清理工作,一般留空 } }

这段代码有几个设计取舍值得说。第一,resolveToken里 header 优先于 URL 参数优先于 Cookie,是因为 header 最不容易被日志记录、URL 参数最容易被记录,优先级反映的是安全性排序。第二,解析失败一律返回false且不抛异常,因为抛异常会导致握手以 500 结束,前端看到的是服务器错误,容易误判成后端挂了;返回false是干净的 403 语义。第三,attributes里只放必要的用户标识,别把整个用户对象塞进去,那个 Map 会跟着连接存活整个生命周期,放大了内存占用。

4. 拦截器里注入 Spring Bean 的三种解法与踩坑实录

前面提过,@ServerEndpoint的interceptors属性指定的拦截器类由 WebSocket 容器实例化,不走 Spring,所以拦截器里@Autowired的字段全是 null。这个问题我第一次遇到时,日志里疯狂 NPE,排查了半天才反应过来是实例化来源的问题。三种解法,按推荐度排序。

4.1 静态工具类持有 ApplicationContext

最直接的办法,写一个ApplicationContextAware的实现,把上下文存到静态字段,拦截器里手动getBean:

@Component public class SpringContextHolder implements ApplicationContextAware { private static ApplicationContext context; @Override public void setApplicationContext(ApplicationContext ctx) { SpringContextHolder.context = ctx; } public static <T> T getBean(Class<T> clazz) { return context.getBean(clazz); } }

拦截器里这样用:

TokenParser parser = SpringContextHolder.getBean(TokenParser.class);

优点是简单粗暴、到处能用;缺点是静态状态不优雅,单元测试时得手动初始化上下文,而且如果 Bean 初始化顺序有问题,可能在极早期调用时拿到 null。生产项目里用得多,但要确保SpringContextHolder是最早初始化的 Bean 之一。

4.2 改用 Spring 的 WebSocketHandler 体系

如果你不执着于@ServerEndpoint注解,改用 Spring 原生的WebSocketHandler+WebSocketConfigurer注册方式,拦截器就能正常走 Spring 管理:

@Configuration @EnableWebSocket public class WsConfig implements WebSocketConfigurer { @Autowired private AuthHandshakeInterceptor authInterceptor; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(myHandler(), "/ws/chat") .addInterceptors(authInterceptor) .setAllowedOrigins("*"); } @Bean public WebSocketHandler myHandler() { return new MyWebSocketHandler(); } }

这种方式下AuthHandshakeInterceptor加个@Component就能正常注入依赖,整个链路都在 Spring 容器里,最干净。代价是端点逻辑得继承TextWebSocketHandler之类的基类,写法跟@ServerEndpoint不一样,@OnMessage那套注解用不了。如果你的项目是从零开始,我强烈建议走这条路;如果是存量项目改造,迁移成本得掂量。

4.3 手动 new 拦截器并传入依赖

还有一种折中:不用interceptors属性,而是在配置类里手动构造拦截器实例并注入依赖,再通过ServerEndpointExporter的变体或者编程式注册。但@ServerEndpoint的interceptors属性只接受 Class 数组,没法传实例,所以这条路在纯注解模式下走不通,只能配合 4.2 的方式。所以实际上就是二选一:要么静态上下文,要么换体系。

我个人的选择:新项目一律用WebSocketHandler体系,拦截器天然是 Spring Bean,省心。老项目如果端点逻辑已经写死在@ServerEndpoint里,用静态上下文兜底,别为了优雅去重构,风险大于收益。

5. 握手成功之后:attributes 怎么在端点里取出来

鉴权通过只是第一步,业务逻辑得知道“当前连接是谁”。beforeHandshake里塞进attributes的用户信息,在@OnOpen里这样取:

@OnOpen public void onOpen(Session session, EndpointConfig config) { Map<String, Object> attrs = config.getUserProperties(); Object userId = attrs.get("userId"); if (userId == null) { // 理论上不会走到这里,因为拦截器已经拦了,但防御性编程 try { session.close(); } catch (IOException ignored) {} return; } // 把 userId 跟 session 关联起来,存到在线用户表 OnlineSessionRegistry.register((Long) userId, session); }

这里有个细节:config.getUserProperties()返回的 Map 和拦截器里操作的attributes是同一个引用,所以能拿到。但如果你用的是 Spring 的WebSocketHandler体系,取法不一样,得从WebSocketSession.getAttributes()拿。两套体系的 API 别混用,混了就是空指针。

还有个并发问题得提:@OnOpen里往在线用户表注册 session,@OnClose里注销,这两个操作如果用的是普通HashMap,多连接并发时会出问题。用ConcurrentHashMap,key 是 userId,value 是 session 集合(一个用户可能多端登录)。注销时注意判断集合是否为空再移除,否则会有内存泄漏——空集合一直挂在 map 里。

6. 那些文档里不会写的实战坑

6.1 跨域配置与 setAllowedOrigins 的陷阱

WebSocket 握手是 HTTP 请求,同样受同源策略约束。前端页面在http://localhost:8080,WebSocket 服务在http://localhost:9090,不配跨域就是握手失败。Spring 的setAllowedOrigins("*")看似万能,但注意:当请求带 Cookie 时,浏览器不允许Access-Control-Allow-Origin: *配合credentials: true,必须指定具体域名。所以生产环境老老实实配白名单,别图省事写星号。

registry.addHandler(myHandler(), "/ws/chat") .addInterceptors(authInterceptor) .setAllowedOrigins("https://your-domain.com");

@ServerEndpoint注解方式下没有直接的跨域配置入口,得靠ServerEndpointConfig.Configurator重写checkOrigin方法,或者干脆在网关层处理。这也是我推荐WebSocketHandler体系的原因之一,配置项更完整。

6.2 反向代理下的握手超时与连接保持

应用前面挂了 Nginx 之类的反向代理时,WebSocket 需要显式配置升级头透传:

location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; }

proxy_read_timeout默认 60 秒,意味着连接空闲 60 秒就被代理掐断。WebSocket 是长连接,不配这个参数,用户会发现“聊着聊着突然断了”,而且断得毫无规律。配成 3600 秒或者更长,同时在应用层加心跳(客户端定时发 ping,服务端回 pong),双保险。心跳间隔建议 30 秒左右,太频繁浪费资源,太稀疏起不到保活作用。

6.3 拦截器返回 false 后前端收到什么

这个必须实测过才知道。beforeHandshake返回false,客户端onerror会触发,onclose也会触发,code通常是 1006(异常关闭)。前端不能靠 code 区分“鉴权失败”和“网络断开”,因为都是 1006。想要精确的错误信息,得在拦截器里往 response 写状态码和 body:

response.setStatusCode(HttpStatus.UNAUTHORIZED); response.getBody().write("{\"code\":401,\"msg\":\"token invalid\"}".getBytes(StandardCharsets.UTF_8)); return false;

但注意,浏览器原生 WebSocket API 拿不到握手失败的响应体,只能拿到失败事件。所以前端的最佳实践是:连接失败后,先调一个普通的 HTTP 接口验证登录态,登录态失效就跳登录页,有效才重连。别在 WebSocket 的错误回调里死磕原因。

6.4 端点实例与线程安全

再强调一遍:@ServerEndpoint标注的类,每个连接一个实例。所以你在类里定义的成员变量是“连接级”的,不是“应用级”的。想存全局状态(比如在线用户表),必须用静态的ConcurrentHashMap或者独立的 Spring Bean。我见过有人在端点类里写private static List<Session> sessions = new ArrayList<>(),多线程下 add 直接抛ConcurrentModificationException,改成CopyOnWriteArrayList才稳。

7. 一套可复用的鉴权链路收尾清单

把上面这些串起来,一个完整的 WebSocket 登录验证链路是这样的:客户端发起握手,带上 Cookie 或 URL 参数里的 token;AuthHandshakeInterceptor.beforeHandshake按优先级提取 token,解析失败直接返回 false 并写 401;解析成功把 userId 塞进 attributes 返回 true;握手完成,@OnOpen从config.getUserProperties()取 userId,注册到在线表;消息处理时按 userId 做业务隔离;@OnClose注销。整条链路里,拦截器是唯一的鉴权闸口,端点内部不再重复校验,职责清晰。

最后分享一个我调试时常用的小技巧:在拦截器的beforeHandshake开头打一行日志,把 URI、query、关键 header 打出来,握手失败时一眼就能看出是凭证没带、带错了、还是解析逻辑有 bug。这行日志在排查“为什么连不上”时比任何断点都快。等链路稳定了再把它降级成 debug 级别,别一直 info 刷屏。

这套方案我在几个中等规模的项目里跑过,单机几千连接的量级下没出过鉴权相关的幺蛾子。真正需要额外操心的是连接数上去之后的 session 管理和心跳保活,那是另一个话题了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询