资讯动态

SpringBoot 3 WebSocket 握手拦截器实现登录鉴权与安全连接

发布时间:2026/10/27 15:42:41 来源:尧图企业网站定制
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和ServerHttpResponseheader、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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency这个 starter 已经把spring-websocket和底层的 WebSocket 实现默认 Tomcat 的 websocket 支持带进来了。不需要额外引jakarta.websocket-apistarter 传递依赖里有了手动再引反而可能版本冲突。版本上有个细节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体系需要额外注册一个ServerEndpointExporterConfiguration 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, MapString, 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 可能不带需配withCredentialsURL 参数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, MapString, 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 ListHttpCookie 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的实现把上下文存到静态字段拦截器里手动getBeanComponent public class SpringContextHolder implements ApplicationContextAware { private static ApplicationContext context; Override public void setApplicationContext(ApplicationContext ctx) { SpringContextHolder.context ctx; } public static T T getBean(ClassT clazz) { return context.getBean(clazz); } }拦截器里这样用TokenParser parser SpringContextHolder.getBean(TokenParser.class);优点是简单粗暴、到处能用缺点是静态状态不优雅单元测试时得手动初始化上下文而且如果 Bean 初始化顺序有问题可能在极早期调用时拿到 null。生产项目里用得多但要确保SpringContextHolder是最早初始化的 Bean 之一。4.2 改用 Spring 的 WebSocketHandler 体系如果你不执着于ServerEndpoint注解改用 Spring 原生的WebSocketHandlerWebSocketConfigurer注册方式拦截器就能正常走 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) { MapString, 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里往在线用户表注册 sessionOnClose里注销这两个操作如果用的是普通HashMap多连接并发时会出问题。用ConcurrentHashMapkey 是 userIdvalue 是 session 集合一个用户可能多端登录。注销时注意判断集合是否为空再移除否则会有内存泄漏——空集合一直挂在 map 里。6. 那些文档里不会写的实战坑6.1 跨域配置与 setAllowedOrigins 的陷阱WebSocket 握手是 HTTP 请求同样受同源策略约束。前端页面在http://localhost:8080WebSocket 服务在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 写状态码和 bodyresponse.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 ListSession sessions new ArrayList()多线程下 add 直接抛ConcurrentModificationException改成CopyOnWriteArrayList才稳。7. 一套可复用的鉴权链路收尾清单把上面这些串起来一个完整的 WebSocket 登录验证链路是这样的客户端发起握手带上 Cookie 或 URL 参数里的 tokenAuthHandshakeInterceptor.beforeHandshake按优先级提取 token解析失败直接返回 false 并写 401解析成功把 userId 塞进 attributes 返回 true握手完成OnOpen从config.getUserProperties()取 userId注册到在线表消息处理时按 userId 做业务隔离OnClose注销。整条链路里拦截器是唯一的鉴权闸口端点内部不再重复校验职责清晰。最后分享一个我调试时常用的小技巧在拦截器的beforeHandshake开头打一行日志把 URI、query、关键 header 打出来握手失败时一眼就能看出是凭证没带、带错了、还是解析逻辑有 bug。这行日志在排查“为什么连不上”时比任何断点都快。等链路稳定了再把它降级成 debug 级别别一直 info 刷屏。这套方案我在几个中等规模的项目里跑过单机几千连接的量级下没出过鉴权相关的幺蛾子。真正需要额外操心的是连接数上去之后的 session 管理和心跳保活那是另一个话题了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑