1. 项目缘起与整体设计思路拆解
登录验证这件事,放在传统的HTTP请求里,大家闭着眼睛都能写出来:拦截器取Token、校验、放行或者打回。但一旦把通信协议换成WebSocket,很多人的第一反应是懵的——握手阶段怎么带凭证?握手成功之后这条长连接还认不认人?拦截器还能不能像以前那样拦住它?我在实际项目里就踩过这个坑:前端拿着登录后拿到的Token去连WebSocket,结果连接是通了,但后端完全不知道这条连接属于谁,消息广播的时候只能靠客户端自己上报用户ID,安全性约等于零。
这个项目要解决的核心问题,就是在SpringBoot3环境下,把WebSocket的登录验证做成一套可靠、可复用、和HTTP接口验证逻辑保持一致的方案。具体来说,它要干三件事:第一,在WebSocket握手阶段完成身份校验,没登录的直接拒绝连接;第二,把校验通过的用户身份绑定到这条连接上,后续收发消息都能识别是谁;第三,用拦截器机制把验证逻辑抽出来,避免每个Handler里重复写校验代码。
适合谁来参考?如果你已经会用SpringBoot写REST接口,对拦截器、Token这些概念不陌生,但第一次接触WebSocket,或者之前做WebSocket验证时用的是“连接后再发一条登录消息”这种土办法,那这套方案能帮你把架构理顺。哪怕你是刚入门的新手,只要跟着步骤走,也能把整个链路跑通,因为我会把每一步的“为什么”都讲清楚,而不是甩一堆代码让你自己悟。
技术选型上,SpringBoot3是硬性前提,因为它对应Spring Framework 6和Jakarta EE 9+,包名从javax换成了jakarta,很多老教程里的WebSocket配置直接抄过来会编译不过。WebSocket的实现我用的是Spring自带的spring-boot-starter-websocket,它底层封装了原生WebSocket和STOMP两种模式。这里我选的是原生WebSocket + 自定义拦截器的组合,而不是STOMP。原因很简单:STOMP虽然自带一些会话管理能力,但它的拦截器体系(ChannelInterceptor)和HTTP的HandlerInterceptor是两套东西,学习成本高,而且对于大多数“点对点消息+广播”的场景,原生WebSocket足够用,控制粒度还更细。
整体设计思路可以概括成一句话:把WebSocket握手当成一次特殊的HTTP请求来处理。WebSocket连接建立之前,客户端会先发一个HTTP Upgrade请求,这个请求里可以带Header、可以带查询参数,那我们就利用这个特性,把Token塞进去,然后在服务端的握手拦截器里把它拦下来校验。校验通过,放行握手,同时把用户信息存到WebSocketSession的attributes里;校验失败,直接返回401,连接根本建立不起来。这样一来,验证逻辑和HTTP接口的拦截器逻辑高度对称,维护起来心智负担很小。
注意:握手拦截器(
HandshakeInterceptor)和消息拦截器(ChannelInterceptor)是两个不同阶段的东西,前者管“能不能连”,后者管“连上之后消息怎么处理”。登录验证要放在握手阶段,别搞混了。
2. 核心细节解析与实操要点
2.1 握手阶段为什么是验证的最佳切入点
很多人做WebSocket验证时,习惯让连接先建立,然后客户端发一条“登录”消息,服务端收到后再标记这个session已认证。这种做法我早期也用过,后来发现三个致命问题。第一,在认证完成之前,这条连接已经存在于服务端了,攻击者可以疯狂建立连接但不发登录消息,服务端资源被白白占用,相当于给了一个免费的连接池攻击入口。第二,认证状态和连接状态是分离的,代码里到处要判断“这个session认证了没”,稍不注意就漏掉某个消息类型没做校验。第三,连接建立和认证之间有时间窗口,这期间的消息处理逻辑处于一种“半信任”的尴尬状态。
握手阶段验证就没有这些问题。WebSocket协议规定,客户端发起连接时先发一个HTTP请求,服务端返回101 Switching Protocols才算握手成功。这个HTTP请求和普通请求一样,能带Header、能带Cookie、能带URL参数。我们在服务端的HandshakeInterceptor的beforeHandshake方法里做校验,返回false就直接拒绝,客户端收到的是握手失败,连接压根不存在。这就像进小区大门时保安查门禁卡,没卡的人连院子都进不来,而不是进了院子再挨个问“你登记了吗”。
具体到代码层面,beforeHandshake方法的签名是这样的:
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception那个attributes参数是关键,它是一个Map,往里面放的东西会绑定到这条连接上,后续在Handler里可以通过session.getAttributes()取出来。我们校验完Token,把用户ID、用户名这些信息塞进去,后面处理消息时直接取,不用再查一遍数据库。
2.2 Token的传递方式与安全考量
Token怎么从客户端传到握手请求里,这里有几个选择,各有优劣,我列个表对比一下。
| 传递方式 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| URL查询参数 | ws://host/ws?token=xxx | 实现最简单,前端拼接方便 | Token会出现在URL里,可能被日志记录,安全性稍差 |
| 请求Header | 握手时设置Header | 相对隐蔽,不落URL日志 | 原生WebSocket API不支持自定义Header,需要借助子协议或Cookie变通 |
| Cookie | 依赖浏览器自动携带 | 前端零改动 | 跨域场景下Cookie策略复杂,移动端不友好 |
| Sec-WebSocket-Protocol | 把Token塞进子协议字段 | 原生API支持,不落URL | 语义上有点“借用”,需要服务端配合解析 |
我在实际项目里用的是URL查询参数方案,原因很实际:前端用的是原生WebSocket对象,它构造函数只接受URL和可选的子协议,没法设Header。虽然可以用Sec-WebSocket-Protocol玩花活,但代码可读性差,团队里新人一看就懵。URL带Token虽然理论上会进访问日志,但只要生产环境把WebSocket握手请求的日志级别调高、不记录完整URL,风险是可控的。如果项目对安全性要求极高,那就上Cookie方案,配合HttpOnly和Secure属性,浏览器自动带,前端代码最干净。
提示:不管用哪种方式传Token,服务端校验完一定要做两件事——检查Token有效期、检查Token是否在黑名单里(比如用户主动登出后Token应失效)。只验签名不验状态,等于门禁卡丢了还能刷开门。
2.3 拦截器的注册与路径匹配
SpringBoot3里注册WebSocket握手拦截器,是通过WebSocketConfigurer接口实现的。这里有个细节很多人会忽略:registry.addHandler(handler, "/ws")的路径匹配规则。如果你写成/ws/**,那/ws/chat、/ws/notify都会走同一个Handler;如果写成/ws,就只有精确匹配。我建议按业务拆分路径,比如聊天走/ws/chat,通知走/ws/notify,每个路径注册独立的Handler和拦截器,这样验证逻辑可以按业务定制,比如聊天要求登录,某些公开通知可能允许匿名。
注册代码大概长这样:
@Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatHandler, "/ws/chat") .addInterceptors(authHandshakeInterceptor) .setAllowedOrigins("*"); } }setAllowedOrigins这个设置要小心,开发阶段图省事写*没问题,生产环境必须改成具体的前端域名,否则跨站WebSocket劫持(CSWSH)的风险就来了。攻击者可以在自己的页面里用受害者的身份去连你的WebSocket,因为浏览器会自动带上Cookie。虽然我们用URL传Token能规避一部分,但Origin校验是纵深防御的基本功,别省。
2.4 用户身份与Session的绑定策略
握手校验通过后,用户信息存进attributes,但这里有个坑:attributes是Map<String, Object>,存进去的对象在后续Handler里取出来是Object类型,每次都要强转。我的做法是定义一个AuthUser的简单POJO,存userId和username,取的时候统一封装一个工具方法:
public static AuthUser getAuthUser(WebSocketSession session) { return (AuthUser) session.getAttributes().get("authUser"); }另一个要考虑的是同一用户多端连接的问题。一个账号可能在手机和电脑上同时连WebSocket,服务端会有两条session。如果业务要求“单端登录”,那就在握手拦截器里维护一个Map<userId, WebSocketSession>,新连接进来时把旧连接关掉。如果允许多端,那就用Map<userId, Set<WebSocketSession>>。这个决策要在设计阶段定下来,因为消息推送逻辑会受影响——单端推送直接找唯一session,多端要遍历集合。
3. 实操过程与核心环节实现
3.1 环境搭建与依赖配置
先把项目骨架搭起来。SpringBoot3要求JDK17起步,这个没得商量,JDK8跑不起来。用IDEA新建项目时选Spring Initializr,依赖勾选Spring Web和WebSocket,或者手动在pom.xml里加:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>版本号交给SpringBoot的parent管理,别自己指定,容易和Spring Framework版本打架。我见过有人手动指定spring-websocket的版本,结果和Boot3自带的版本冲突,启动时报NoSuchMethodError,排查半天。
配置文件里加两行,主要是调一下消息大小限制,默认值有时候不够用:
spring: websocket: max-text-message-size: 8192 max-binary-message-size: 81923.2 握手拦截器的完整实现
这是整个项目的核心类,我把它拆成三段来看。第一段是取Token,第二段是校验,第三段是绑定身份。
@Component public class AuthHandshakeInterceptor implements HandshakeInterceptor { @Autowired private TokenService tokenService; @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception { // 第一段:从URL参数取Token String token = null; if (request instanceof ServletServerHttpRequest) { ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request; token = servletRequest.getServletRequest().getParameter("token"); } // 第二段:校验Token if (token == null || token.isBlank()) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } AuthUser user = tokenService.verifyAndParse(token); if (user == null) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } // 第三段:绑定身份到连接 attributes.put("authUser", user); attributes.put("connectTime", System.currentTimeMillis()); return true; } @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 握手完成后回调,一般不用做什么 } }beforeHandshake返回false时,Spring会中断握手流程,客户端收到的是HTTP 401响应,onerror回调会被触发。前端要处理这个错误,给用户提示“登录已过期,请重新登录”,而不是傻等着连接超时。
afterHandshake方法里有个exception参数,如果握手过程中抛了异常,这里能拿到。我一般在这里打一条日志,方便排查“为什么连接建立失败”的问题。注意这个方法在握手成功和失败时都会调用,别在里面做“连接成功”的业务逻辑,会误判。
3.3 Token校验服务的实现要点
TokenService的verifyAndParse方法,我建议用JWT实现,因为它是无状态的,服务端不用存session,水平扩展方便。核心逻辑就三步:验签名、查过期、解析claims。
public AuthUser verifyAndParse(String token) { try { Claims claims = Jwts.parserBuilder() .setSigningKey(secretKey) .build() .parseClaimsJws(token) .getBody(); // 检查是否在黑名单(用户登出后加入) if (blacklistService.contains(token)) { return null; } Long userId = claims.get("userId", Long.class); String username = claims.getSubject(); return new AuthUser(userId, username); } catch (JwtException | IllegalArgumentException e) { return null; } }密钥的管理是个容易被忽视的点。开发环境写在配置文件里没问题,生产环境必须走环境变量或者配置中心,绝对不能硬编码在代码里提交到仓库。我见过有项目把密钥写在application.yml里推到公开仓库,结果被人拿去伪造Token,教训很深刻。
黑名单的实现,简单点用内存Set加定时清理,复杂点用Redis的过期键。如果Token本身有效期就半小时,黑名单里存半小时自动过期,内存压力不大。用户量大的话还是上Redis,别在单机内存里攒。
3.4 消息处理Handler里的身份使用
握手通过后,连接建立,afterConnectionEstablished方法会被调用。这里可以从session的attributes里取用户信息,做上线通知、在线列表维护这些事。
@Override public void afterConnectionEstablished(WebSocketSession session) { AuthUser user = (AuthUser) session.getAttributes().get("authUser"); onlineUserService.add(user.getUserId(), session); log.info("用户 {} 上线,当前在线数 {}", user.getUsername(), onlineUserService.count()); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { AuthUser user = (AuthUser) session.getAttributes().get("authUser"); // 这里不用再校验登录状态,能走到这说明握手已通过 String payload = message.getPayload(); // 业务处理... }注意handleTextMessage里我直接取用户信息就用,没有再判断null。因为能进到这个方法的session,一定是握手拦截器放行的,身份必然存在。这就是把验证放在握手阶段的好处——后续代码里少了一堆防御性判断,逻辑干净。
afterConnectionClosed里要做清理,把用户从在线列表移除。这里有个细节:如果同一用户多端连接,移除时要判断是不是最后一个连接,别一端下线就把整个用户标记为离线。
3.5 前端连接与错误处理
前端代码不复杂,关键是错误处理要到位:
function connectWebSocket(token) { const ws = new WebSocket(`ws://localhost:8080/ws/chat?token=${token}`); ws.onopen = () => { console.log('连接成功'); }; ws.onerror = (event) => { console.error('连接失败,可能是登录过期'); // 触发重新登录流程 }; ws.onclose = (event) => { if (event.code === 1006) { // 异常关闭,考虑重连 setTimeout(() => reconnect(), 3000); } }; return ws; }onerror在握手被拒时触发,但浏览器不会告诉你具体是401还是别的,所以前端要结合自己的登录状态判断。我的做法是:onerror触发后,先调一次HTTP的“检查登录状态”接口,如果返回未登录,就跳登录页;如果登录状态正常,那可能是网络问题,走重连逻辑。
重连策略要加退避,别固定3秒死循环。第一次失败等1秒,第二次2秒,第三次4秒,封顶30秒。不然服务端重启时,所有客户端同时疯狂重连,直接把服务打挂。
4. 常见问题与排查技巧实录
4.1 握手失败但看不到具体原因
这是最高频的问题。客户端只看到连接关闭,服务端日志里啥也没有。排查思路分三步走。第一步,确认拦截器有没有被注册上,在beforeHandshake里打一行日志,看有没有输出。如果没有,说明路径没匹配上,检查addHandler的路径和客户端连接的路径是否一致。第二步,如果日志有输出但返回了false,看是Token为空还是校验失败,分别打日志。第三步,如果拦截器根本没进,检查@EnableWebSocket注解加了没,这个注解漏了的话配置类不生效,但启动不报错,很隐蔽。
还有一种情况是跨域导致的握手失败。浏览器控制台会报Cross origin requests are only supported for protocol schemes之类的错误。解决方法是setAllowedOrigins里加上前端地址,或者开发阶段临时用*。注意SpringBoot3里这个方法的参数是可变参数,可以传多个域名。
4.2 Token在URL里被截断或编码问题
JWT的Token里包含.和可能包含+、/、=这些字符,直接拼在URL里,某些情况下会被截断或转义。我遇到过Token里的+被服务端解析成空格,导致签名校验失败。解决办法是前端用encodeURIComponent编码,服务端取参数时容器一般会自动解码,如果没解码就手动URLDecoder.decode一次。更稳妥的做法是Base64URL编码,JWT标准本身就支持,生成的Token只含-和_,没有特殊字符,放URL里绝对安全。
4.3 连接建立后很快被断开
现象是onopen刚触发,马上onclose。常见原因有三个。第一,服务端有闲置超时设置,比如Nginx的proxy_read_timeout默认60秒,WebSocket连接如果60秒没消息就被代理断开。解决方法是加心跳,客户端每30秒发一个ping消息,服务端收到回pong。第二,Tomcat的maxSessionIdleTimeout设置过小。第三,代码里在afterConnectionEstablished抛了异常,导致连接被关闭,看日志能发现。
心跳的实现,服务端可以在afterConnectionEstablished里起一个定时任务,定期给session发ping;客户端收到ping回pong。或者反过来,客户端定时发,服务端收到就刷新超时。我倾向客户端主动发,因为服务端定时任务多了对性能有影响。
4.4 多端连接时的消息重复推送
用户A在手机和电脑都登录了,服务端给A推消息时,如果遍历session集合发送,两端都会收到,这没问题。但如果业务要求“只在当前活跃端显示”,那就需要维护一个“活跃session”的概念,比如最后发消息的那条连接标记为活跃,推送时只发活跃端。这个逻辑要在handleTextMessage里更新活跃标记,实现起来不复杂,但设计时要想到,不然上线后产品经理提需求就抓瞎。
4.5 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 握手直接失败,无日志 | 拦截器未注册或路径不匹配 | 拦截器内打日志确认是否进入 | 检查@EnableWebSocket和路径配置 |
| 401但Token明明有效 | Token含特殊字符被转义 | 打印服务端收到的原始Token | 前端encodeURIComponent,或用Base64URL |
| 连接秒断 | 代理超时或代码异常 | 看服务端异常日志和代理配置 | 加心跳,调大超时时间 |
| 跨域报错 | Origin未放行 | 浏览器控制台看具体错误 | setAllowedOrigins加前端域名 |
| 消息发不出去 | session已关闭但未清理 | 发送前判断session.isOpen() | 关闭时及时从在线列表移除 |
| 重连风暴 | 客户端固定间隔重连 | 观察服务端连接数曲线 | 指数退避重连策略 |
提示:
session.isOpen()这个判断在发送消息前一定要做。我踩过的坑是:用户关闭页面,服务端还没收到关闭事件,这时候有广播消息过来,往一个已关闭的session发,直接抛异常,把整个广播循环打断了。加个判断,异常session跳过并清理,广播就稳了。
4.6 几个提升稳定性的实操心得
第一个心得:握手拦截器里不要做耗时操作。有人在校验Token时顺便查数据库加载用户权限,一个查询几百毫秒,握手就卡几百毫秒。Token里该带的权限信息提前带好,拦截器里只做内存级的校验,保证握手快速完成。
第二个心得:给session的attributes里存一个连接ID。用UUID生成,日志里打这个ID,排查问题时能把一条连接的所有日志串起来。不然多用户并发时,日志混在一起根本分不清谁是谁。
第三个心得:在线列表的清理要有兜底。正常关闭走afterConnectionClosed,但进程被kill -9时这个方法不执行,在线列表里会残留死连接。加一个定时任务,定期扫描所有session,isOpen()为false的清理掉。频率不用高,五分钟一次足够。
第四个心得:测试时用两个浏览器无痕窗口。普通窗口共享Cookie和登录态,测多用户场景时容易混淆。无痕窗口之间隔离,一个登用户A一个登用户B,互不干扰,测消息推送特别方便。
这套方案我在两个项目里落地过,一个是内部协作工具的实时通知,一个是面向用户的在线客服。前者用户量小,单机内存存session够用;后者上了Redis做session共享,配合网关的粘性会话,扩展性没问题。核心逻辑没变过,都是握手拦截器验Token、attributes绑身份、Handler里直接用。把这套骨架搭好,后面加业务就是填空的事。