news 2026/10/27 15:42:37

SpringBoot3 WebSocket登录验证:握手拦截器与Token绑定实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot3 WebSocket登录验证:握手拦截器与Token绑定实战

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: 8192

3.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里直接用。把这套骨架搭好,后面加业务就是填空的事。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 15:42:10

移动储能在配电网韧性提升中的关键作用与实现

1. 项目概述&#xff1a;移动储能在配电网韧性提升中的关键作用电力系统韧性&#xff08;Resilience&#xff09;是指电网在遭受极端事件干扰后快速恢复供电的能力。近年来&#xff0c;随着极端天气事件频发和分布式能源渗透率提高&#xff0c;传统配电网在故障情况下的供电连续…

作者头像 李华
网站建设 2026/10/27 15:41:58

VMwareTools 10.0.6 安装指南:Linux 虚拟机内核模块编译与功能验证

简介&#xff1a;VMwareTools-10.0.6-3595377.tar 是面向 Linux 虚拟机用户的离线安装包&#xff0c;用于解决在线安装失败或网络受限时无法顺利部署 VMware Tools 的问题&#xff0c;适合有一定 Linux 基础、需要手动编译安装的运维与开发人员。压缩包共 3197 个文件&#xff…

作者头像 李华
网站建设 2026/10/27 15:41:55

代谢组学数据整理:Formula到InChIKey再到HMDB ID的匹配实战

代谢组学数据整理这件事&#xff0c;说难不难&#xff0c;说简单也真不简单。我接触过的不少做代谢组、天然产物分析的朋友&#xff0c;手里攥着一堆化合物列表&#xff0c;Formula 列、InChIKey 列、HMDB ID 列、Common Name 列各占一栏&#xff0c;看着挺规整&#xff0c;真要…

作者头像 李华
网站建设 2026/10/27 15:40:04

WeKnora本地部署实录:知识图谱+RAG构建私有问答系统

WeKnora 本地部署实录&#xff1a;用腾讯微信团队的开源 AI 知识库搭一套问答系统年初团队内部一直嚷嚷着要搞一个私有知识库&#xff0c;把散落的几十篇技术文档、产品手册和会议纪要统一管起来。我一开始用的是纯 RAG 方案&#xff0c;文档一切块就扔进向量库&#xff0c;结果…

作者头像 李华
网站建设 2026/10/27 15:39:49

不依赖Tool Calling:构建结构化JSON驱动的通用Agent

1. 为什么想做一个不带工具调用的Agent 1.1 工具调用的甜蜜与负担 从我最早开始折腾Agent到现在&#xff0c;前后踩过不少坑。刚开始接触Agent&#xff0c;我的第一反应也是上Tool Calling&#xff0c;因为这是大模型应用里最“正统”的玩法&#xff1a;模型在回答时附带一个结…

作者头像 李华
网站建设 2026/10/27 15:38:39

电力系统后台作图工具:国标合规自动制图与拓扑校验

简介&#xff1a;这是一款面向电力系统工程师与自动化专业学习者的后台作图软件&#xff0c;专用于电力网络建模、实时监控可视化及电气设备布局设计&#xff0c;有效解决传统绘图工具符号缺失、动态响应不足、数据联动困难等问题。资源包共108个文件&#xff0c;含28个obj模型…

作者头像 李华