☰
WebSocket鉴权实战:从握手到消息级与生命周期管理
2026/10/19 3:21:06 网站建设 项目流程

WebSocket鉴权这事,说简单也简单,说难也难。简单在于本质就是一次HTTP Upgrade请求,可以在握手阶段塞token;难在于很多团队把HTTP那套思维生搬硬套过来,结果线上各种意外断连、token泄漏、权限绕过,排查起来一头雾水。我前后做过几个带实时推送的业务系统,从早期的“裸奔”模式到后面逐渐沉淀出一套相对完整的鉴权方案,踩了不少坑,这里把思路和实现一起梳理一下。

1. WebSocket鉴权为什么不能照搬HTTP方案

先弄清楚一个核心区别:HTTP请求是“一次性”的,每次请求都能带上完整的鉴权信息,服务端每次都能校验,无状态、好排查。WebSocket不一样,它一旦建立连接,后续通信就是一条长连接上的全双工数据流,不会再自动附带任何cookie或header。鉴权如果只在连接建立时做一次,那这条连接在之后数小时甚至数天的生命周期里,只要不断开,服务端就默认它一直是合法的。

这里面有一个很典型的误解:有人觉得“连接建立时校验过token了,后面就安全了”,实际上这是把鉴权当成了“入场券”,忽略了WebSocket连接天然具备的“状态延续”风险。比如用户的token有效期是30分钟,连接却挂了2个小时,期间用户权限变了、账号被禁用了、token被重置了,这条旧连接依然能收发消息。更危险的是,如果同一token被多个客户端共享,服务端根本区分不了谁是谁。

另外一个问题出在“鉴权信息放哪”。HTTP可以放在Authorization头、cookie、请求体里,选择很多,WebSocket的浏览器端API却有限制。浏览器里new WebSocket(url)没办法自定义header,虽然理论上可以走protocols参数,但那设计初衷是协商子协议,不是为了塞token。这个问题直接决定了前端怎么把token传到后端,也是很多团队第一次实现时卡住的地方。

还有个容易忽略的点:鉴权不等于加密。很多方案只校验证书和token,消息内容明文传输,token即使校验通过了,敏感数据被抓包一样能看。所以设计鉴权方案的同时,要想清楚是否需要wss,是否需要消息体加密,这跟鉴权是两个维度的事。

想清楚这些,就会发现WebSocket鉴权应该分三个层面去做,而不是简简单单验一个token就完事。

2. 鉴权方案设计:握手的入口与消息的关卡

我的落地方案是三层结构:连接握手鉴权、消息级鉴权、生命周期管理。缺一层都有隐患,合在一起才算闭环。

2.1 握手阶段:HTTP Upgrade请求是唯一一次能借助标准头部的机会

浏览器发起WebSocket连接时,会先发一个HTTP Upgrade请求,服务端返回101后连接才建立。这个Upgrade请求里可以带header,可以带cookie,也可以带query参数,这是整个连接生命周期里唯一一次可以用标准HTTP语义传鉴权信息的机会。

我在服务端用的是Go语言,标准的http.Request就能读到这些信息。推荐的方式是靠token而不是session,因为WebSocket场景下,session的分布式共享本身就是一个大坑,而token可以放在query、header、cookie里,后端无状态校验即可。

这里有一个很重要的取舍:走query参数最简单,但token会出现在访问日志里,如果nginx/apache默认打了access log,token就直接裸奔了。走Header稍微安全点,但浏览器原生API设不了header,需要服务端配合一次HTTP请求去换取一个带鉴权信息的WebSocket地址,或者用子协议的方式带token。这个我会在讲到实现的时候展开对比。

2.2 消息级鉴权:面向多页面与分布式后端的兜底防线

只做握手鉴权,等于信任了整条长连接。我自己的项目刚开始就是这么干的,后来出现了一个问题:某个用户在前端页面上通过越权调用,发了一种消息类型,后端没校验就直接处理了,因为后端觉得“连接都鉴权过了,这个消息肯定是他的”。这个想法在WebSocket场景下很危险。

消息级鉴权不是说每条消息都重新校验一次token,那是性能灾难。正确姿势是:连接建立时校验token,同时从token里解析出用户身份(userID),把身份绑定到这条连接上。每条消息进来时,先根据消息类型判断“这个用户有没有权限执行这个操作、操作的数据范围是哪个”。比如普通用户只能发消息给同一项目组的人,管理员才能发全局广播,这种判断必须在每一条消息上做,因为WebSocket是一个无约束的全双工通道,前端想发什么就发什么。

只有握手鉴权没有消息级鉴权,等于把服务端接口裸奔在用户面前,用户拿网页控制台就能调你能收的所有消息类型。

2.3 生命周期鉴权:把token过期、刷新、踢人、断线重连纳入设计

这一层最容易被忽略,但实际运行中大量问题出在这里。一个典型的场景:用户登录后进了WebSocket,token有效期设置的是24小时,他明天下次打开页面时token还能用,这时候连接重新握手的鉴权是没问题的。麻烦的是中间态,比如token已经过期,但WebSocket连接还活着,服务端要不要主动断开?

我的设计是在业务层级加一个“心跳鉴权”的概念:不仅心跳包要回复pong,服务端还会定期(比如每5分钟)检查一遍用户的token是否还在有效期内、用户是否仍被允许访问这个项目。如果在服务端的内存表里做了连接与用户身份的绑定,这个检查只需要遍历在线表就行,开销很小,但能解决“人已经被踢下线,连接还飘着”的尴尬局面。

还有一个必须想清楚的场景是断线重连。移动端网络切换、后端发布重启,连接必然断开,客户端一般有重连逻辑。如果重连时用的是旧token,后端需要能识别“这是前一天同一台设备上的同一个用户”,而不是把旧token一刀切。这里我建议每次重连都刷新token,而不是沿用旧token重试,因为旧token可能在断线期间已经被吊销了。

3. 核心实现:三种握手携带方式的取舍与代码落地

从前端把token传到后端,实际操作里有三条路:query参数、Header、子协议。各有利弊,我一次性讲清楚。

3.1 三种方式的详细对比

携带方式前端实现服务端读取方式优点缺点适用场景
query参数new WebSocket('wss://x?token=xxx')从r.URL.Query()里取实现最简单,不依赖额外协议token会进访问日志;URL长度受限制;token会出现在浏览器历史记录中内网或私有项目,对日志管控有把握
Header(需配合HTTP接口)先请求后端拿一个临时URL或ticket从r.Header.Get("Authorization")里取不暴露在URL和日志里;可以加短期票据逻辑;符合HTTP语义需要额外一次HTTP请求;实现复杂大部分中大型项目,推荐优先考虑
子协议(Sec-WebSocket-Protocol)new WebSocket(url, ['ticket', token])从r.Header.Get("Sec-WebSocket-Protocol")里取能传数据,不算标准鉴权方式语义不清晰;有些代理会对该header做改写;token长度受限部分特殊场景

有人会说webSocket支持自定义Header——这里我多啰嗦一句:浏览器原生API不行,但很多第三方库(比如某些实现WebSocket的客户端SDK)可以自定义header。如果是你在写非浏览器的客户端,比如桌面端、移动端、后端做客户端走WebSocket,直接自定义Header是最干净的方案,因为不受浏览器限制。

3.2 服务端落地:Go语言版握手鉴权

先做一个校验token的函数:

func authenticate(r *http.Request) (*UserIdentity, error) { // 按优先级从不同位置获取token token := r.Header.Get("Authorization") if strings.HasPrefix(token, "Bearer ") { token = token[7:] } if token == "" { // 兼容无法设置Header的场景 token = r.URL.Query().Get("token") } if token == "" { // 再尝试子协议 protocols := r.Header.Get("Sec-WebSocket-Protocol") if protocols != "" { parts := strings.Split(protocols, ",") for _, p := range parts { p = strings.TrimSpace(p) if strings.HasPrefix(p, "token_") { token = strings.TrimPrefix(p, "token_") break } } } } if token == "" { return nil, errors.New("missing token") } // 解析并校验token,这里以JWT为例 parsed, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { return []byte("your_secret_key"), nil }) if err != nil || !parsed.Valid { return nil, errors.New("invalid token") } claims := parsed.Claims.(jwt.MapClaims) uid, _ := claims["uid"].(float64) if uid == 0 { return nil, errors.New("invalid user id") } return &UserIdentity{ UserID: int64(uid), Role: claims["role"].(string), }, nil }

然后在升级之前做鉴权:

var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { // 生产环境要严格校验Origin,防止跨站WebSocket劫持 origin := r.Header.Get("Origin") if origin == "" { return true } return isAllowedOrigin(origin) }, } func wsHandler(w http.ResponseWriter, r *http.Request) { identity, err := authenticate(r) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Printf("upgrade error: %v", err) return } // 绑定连接与用户身份 connMgr.Add(identity.UserID, conn) defer connMgr.Remove(identity.UserID, conn) // 进入消息循环 serveWS(conn, identity) }

这里有一个很容易踩的坑:CheckOrigin不要全部返回true,有些人图省事直接不看来源就放行了,这会直接暴露跨站WebSocket劫持风险。攻击者可以在自己的站里写脚本,让受害者浏览器去连受害网站的WebSocket端口,如果CheckOrigin全放过,攻击脚本就能借用受害者的身份建立连接。生产环境一定维护一个白名单,或者至少比对Origin和Host的域名部分。

3.3 前端配合:如何处理Header受限

浏览器端不能自定义Header,我用的方案是“先换URL再连接”:前端在WebSocket连接前,先向后端发起一次HTTP请求,后端返回一个一次性票据ticket(一般带5秒过期时间),前端再用wss://xxx?ticket=xxx去建立WebSocket连接。服务端收到后,用ticket换取真实的用户身份,并且把ticket标记为已使用,防止重放。

这个ticket设计在最里面解决token暴露的问题,因为ticket是一次性且短期的,即使出现在日志里,也无法被用来冒充用户。

关键代码:

// 生成ticket的接口 func getTicketHandler(w http.ResponseWriter, r *http.Request) { // 这是普通的HTTP接口,正常走你的登录态校验 uid := getCurrentUserID(r) ticket := generateRandomTicket() ticketStore.Set(ticket, uid, 5*time.Second) json.NewEncoder(w).Encode(map[string]string{ "url": "wss://yourdomain.com/ws?ticket=" + ticket, }) } // WebSocket握手时通过ticket换取身份 func authenticateByTicket(r *http.Request) (*UserIdentity, error) { ticket := r.URL.Query().Get("ticket") if ticket == "" { return nil, errors.New("missing ticket") } uid, ok := ticketStore.GetAndDelete(ticket) if !ok { return nil, errors.New("invalid or expired ticket") } return &UserIdentity{UserID: uid}, nil }

4. 连接与状态管理:心跳、重连、在线表与踢人逻辑

鉴权只是第一步,连上了之后,连接状态的维护其实和鉴权耦合得很深。很多系统上线后出现“用户明明下线了,状态还显示在线”,或者“踢人踢不掉,重新登录又顶不掉旧连接”,这些都是因为没有把连接管理设计好。我的做法是把连接管理做成独立的组件,和鉴权模块配合起来。

4.1 在线连接表与并发控制

服务端维护一张全局的在线表,存储userID -> 连接集合。注意是集合,不是单条连接,因为同一个用户很可能同时在浏览器、手机、桌面端登录,三个设备就是三条连接。

type ConnManager struct { mu sync.RWMutex conns map[int64]map[*websocket.Conn]struct{} } func (m *ConnManager) Add(uid int64, conn *websocket.Conn) { m.mu.Lock() defer m.mu.Unlock() if m.conns[uid] == nil { m.conns[uid] = make(map[*websocket.Conn]struct{}) } m.conns[uid][conn] = struct{}{} } func (m *ConnManager) Remove(uid int64, conn *websocket.Conn) { m.mu.Lock() defer m.mu.Unlock() if set, ok := m.conns[uid]; ok { delete(set, conn) if len(set) == 0 { delete(m.conns, uid) } } }

为什么用集合而不是单个连接?因为一个userID下存在多条连接非常正常,单连接结构会在第二个设备登录时把第一个设备顶下线,这个产品上需要明确需求才能这么干。

4.2 心跳机制和死连接清理

心跳是整个长连接系统最基础的保障之一。不设心跳,就会出现大量半开连接:网络断开时TCP不会立刻通知到应用层,服务端以为连接还在,其实客户端早就掉线了。长此以往,在线表里会堆积一堆僵尸连接,推送消息时还会触发一堆写超时错误。

我用的心跳间隔是30秒,服务端启动一个定时器,每隔30秒对所有连接发送一次ping消息(WebSocket协议层自带的心跳帧),客户端收到后返回pong。服务端将最后一次pong时间记录下来,超过90秒没有pong,就强制关闭这个连接。

这两个数值不是拍脑袋定的,需要考虑移动端省电和弱网环境。间隔太短会让移动端频繁唤醒,电量掉得厉害;间隔太长又会让僵尸连接存活久,占用资源。30秒/90秒是比较通用的配置,如果你服务的场景是PC网页端,可以更快,比如10秒/30秒。

4.3 踢人与权限变更后的强制下线

“踢人”这个操作在产品里有很多变体:管理员封禁用户、用户修改密码后旧设备失效、同名账号互踢。核心问题都一样:如何让服务端主动断开指定连接。

再进一步:断开之后,如果客户端自动重连,那踢人就没意义了,因为旧token如果还是有效的,重连就会直接成功。所以踢人的正确姿势是两步走:

  1. 服务端吊销旧的token或记录一个黑名单状态;
  2. 服务端主动关闭该用户的所有连接。

顺序很重要,先吊销再断开。否则中间有个时间窗口,客户端自动重连并成功。

实现上,踢人逻辑就是遍历该用户的连接集合,写入关闭帧,然后关闭连接:

func (m *ConnManager) KickUser(uid int64, reason string) { m.mu.RLock() conns := m.conns[uid] m.mu.RUnlock() for conn := range conns { // 使用控制消息通知客户端被踢原因 conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(4001, reason), time.Now().Add(5*time.Second)) m.Remove(uid, conn) conn.Close() } }

我这里定义了一个自定义错误码4001,业务上专门表示“被强制下线”。客户端收到这个错误码后,应该停止自动重连逻辑,否则会陷入“被踢-重连-再被踢”的死循环。

4.4 断线重连与重新鉴权

重连逻辑里最大的坑是“沿用旧token直接重试”。假设用户的token有效期为2小时,他1小时前登录,中间网络断了,客户端立即用旧token重连——这表面上没问题。但如果断网发生在token过期前10分钟,等到网络恢复时旧token已经失效,重连失败。在弱网环境(地下车库、电梯里),这种情况很频繁。

我的方案是客户端在重连前先调用刷新token接口,用refreshToken换一个新的accessToken,再用新token发起WebSocket连接。如果refreshToken也过期了,客户端才认为需要重新登录。

5. 安全加固与常见绕过路径

鉴权方案做完了,不代表就安全了。我在安全测试阶段总结了几条绕过路径,不防住等于白做。

5.1 重放攻击与票据一次性

用票据方案换URL时,一定要确保ticket只能使用一次,并且要设置极短的有效期。我之前遇到过测试环境里把ticket有效期设置成10分钟,结果安全测试的人拿到别人分享的URL后,10分钟之内都能直接用那个ticket连上。改成5秒有效且消费一次后立即失效,就堵住了。

5.2 权限提升与消息类型校验

一个常见攻击:用户用websocket客户端(比如在线调试工具)连接服务端,手动构造一个“管理员广播”的消息类型,如果服务端只在握手时校验了身份,没有在消息处理时校验角色,这个用户就能实现权限提升。解决方式很简单,但也最容易偷懒:每种消息类型的处理函数里都必须做一次“当前用户是否有权执行该操作”的校验。这不是重复劳动,是WebSocket这种无约束通道下必须做的事。

5.3 消息伪造与来源校验

WebSocket连接建立后,服务端默认信任这条连接的所有输入。但实际上,如果身份信息只是在URL里放了一次,之后就没有任何参照物,服务端接收到的每条消息其实是“无法区分来自谁”的——它只能通过连接上绑定的身份来判断。问题来了:一旦连接被劫持,或者客户端代码被注入恶意脚本(比如XSS),攻击者就可以借用这个合法连接为所欲为。

配合的方案是:在消息体里带一个短暂时效的消息签名,或者至少带一个单调递增的序号,服务端校验序号连续性。这种方案在金融类、对账类的实时系统里很有必要,一般的聊天应用可以放宽。我自己实践下来,轻量的做法是消息体里加一个客户端生成的随机数nonce,服务端缓存最近收到的nonce,重复出现的直接丢弃,能防一部分重放。

5.4 WSS与代理层注意事项

生产环境一定要用wss而不是ws,除非你明确知道上层的负载均衡已经终结了TLS。另外,很多云厂商的负载均衡对WebSocket协议的支持细节不一致,比如对socket.io这类框架,长连接的闲置超时时间由负载均衡的“连接空闲超时”控制,如果LB设置为60秒,而你的心跳是30秒就没事;心跳如果是90秒,LB就会先把没有数据传输的连接切断。

这个在压测和上线前必须查清楚,否则会出现“连接稳定运行几分钟后全部断开”的诡异问题。排查方法很简单:看负载均衡的访问日志,有没有出现大量的101升级后紧接着4xx或5xx的日志,以及后端连接数是否周期性清空。之前有同事排查类似问题,最后发现是LB层的连接超时设置导致,而服务端日志看起来一切都正常。

6. 常见问题与排查实录:误连、断连、鉴权失效、并发踢下线

最后整理一下我自己及团队在实际运维中踩过的问题,做成速查表,再展开讲讲排查思路。

现象可能原因排查方法
握手一直401token过期 / 签名算法不匹配 / 时钟偏差检查服务端时间和客户端时间,JWT的iat和exp做宽容处理
连接偶尔断开负载均衡闲置超时 / 心跳间隔大于LB超时时间调短心跳间隔,或调整LB配置
客户端显示在线,服务端推送失败僵尸连接没清理检查心跳pong时间有没有在更新,增加僵尸连接自动清理任务
用户被踢后立即自动重连成功踢人时没有先吊销token调整顺序:先吊销token,再断开连接
一个用户多次重复登录导致在线列表爆炸每次连接都新增记录,没有踢掉旧连接根据产品需求,决定是否启用“单设备登录”策略,如果需要,做“同类型设备互踢”
所有连接在固定时间点集体断开多半是负载均衡周期性回收,或者服务端定时任务误关闭看服务端日志里有没有输出主动关闭的日志,检查定时任务是否触碰了连接池

6.1 握手阶段鉴权成功但消息收发失败

这个现象很有迷惑性。握手鉴权已经是200了,连接也建立了,但客户端发送消息后服务端没有任何响应,客户端的状态看起来还是OPEN,发出去的消息石沉大海。

我遇到的一次:消息处理函数里做了用户角色判断,但角色信息从JWT的role字段里取的时候,类型断言写错了,解析出来一直是空字符串,导致所有消息都被判定为“无权限”。处理函数里只是静默丢弃返回了一个错误码,客户端没有处理错误码就当作成功了。

这个问题的核心教训是:鉴权失败和消息处理失败都应该有明确的错误协议,不能静默丢弃。我后来规定:业务消息必须返回一个对应的Ack,哪怕是一条默认的“已收到”也行,至少让客户端知道链路是通的。

6.2 token过期和连接存活的矛盾

这属于设计层面的纠偏。早期版本我确实只做握手鉴权,当时想的是“token过期了,再重连不就行了”。但真实用户行为里有太多长时间挂机的情况,token过期了连接还挂着,后端要基于用户身份推送系统通知的时候,发现这个用户被判定为“频繁掉线”。

后来我在心跳循环里叠加了一层“Token有效性检查”:每5分钟检查一次在线表里所有连接的用户的token过期时间,如果已经过期或者接近过期(比如剩余时间小于2分钟),服务端主动给客户端下发一个token_expiring事件,客户端收到这个事件后后台调用刷新接口换取新token,再把新token通过一条普通消息发给服务端更新。这种做法的好处是用户无感知,连接也不断。

6.3 并发连接数与推拉消息稳定性

做实时服务的人都绕不开一个问题:连接数一多,推送性能就下降。我自己遇到过一次线上事故:连接数从几千涨到上万后,某些连接开始反复断开重连,服务端CPU和内存看着都正常,但推送延迟越来越高。

后来定位到是写超时设置得不合理。推送消息时用的写超时时间是100毫秒,正常情况下够了。但有一次推送的是一个超大消息体(一整个对象,包含了大量字段),序列化之后很大,写入TCP缓冲区时超过100毫秒,直接超时断开。大量连接同时推这种大消息,就造成了“批量抖动”。

解决办法也很粗暴但有效:拆分消息体,把一次性推送的大JSON拆成几类小消息分别推送;同时把写超时调整为500毫秒,并给每个连接设置一个独立的发送队列,避免一个连接慢导致整个goroutine阻塞。现在推送大消息也没再出现过抖动了。

6.4 多实例部署时的鉴权状态一致性

服务端一旦上了多个实例,在线连接表就不能只存在单机内存里了,否则用户连接在实例A,消息推送到实例B就找不到这条连接。鉴权这里也同理:踢人操作需要让所有实例都知道用户已被踢。

我采用的方式是:引入一个轻量级的消息中间件(或者用Redis的Pub/Sub也可以),实例A发起踢人动作时,广播一条“用户12345已被封禁”的消息,其他实例收到后各自关闭本机上的对应连接。这比把所有连接状态都存Redis再同步要简单得多,也够用。连接和鉴权状态保持一致的关键是:状态变更必须走广播,而不是走后端直接操作DB后再让各个实例自己去查。

6.5 调试WebSocket的实用技巧

最后唠一点排查工具的使用心得。浏览器开发者工具的Network面板可以看到WebSocket的帧记录,但只能看到一个方向的帧,对于调试双向消息交互,我更喜欢直接用命令行工具来做连接测试。很多人在排查问题时只会用现成的在线调试网站,但那类网站通常只能建立裸连接,不支持自定义协议和鉴权头,实际情况是高仿不出来。

写一个简单的Go或Node测试脚本,模拟一个客户端,携带token和自定义Header去连接服务端,问题基本能复现十有八九。脚本里可以把收发消息、心跳、错误码都打出来,比在浏览器里翻帧方便得多。这个脚本我建议直接放在代码仓库的scripts/目录下,当作项目的调试工具长期维护,团队成员排查问题都用得上。

WebSocket鉴权这件事,本质上就是在HTTP无状态模型和长连接有状态模型之间做一个折中。握手阶段借用HTTP的语义完成身份认证,连接建立后在业务层自己做会话管理、心跳检测、权限校验和生命周期控制。很多人觉得WebSocket鉴权难,难不在技术本身,而在于对长连接的“状态延续”没有足够敬畏。如果能把身份绑定、权限校验、连接生命周期这三点想清楚,任何项目都能设计出一套够用且不容易出问题的方案。

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

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

立即咨询