长连接鉴权:握手期令牌、连接级权限与踢下线
浏览器的 WebSocket API 不允许自定义请求头,Authorization 写不进去——这是长连接鉴权所有别扭之处的源头。于是常见两种做法:把 token 拼在 URL 上(它随后会出现在 Nginx access.log、浏览器历史与 Referer 里),或者「先升级、再用第一帧消息发 token」。后者的代价是攻击者可以零成本建立海量匿名连接:升级完成就分配了 fd、goroutine 与读写缓冲区,鉴权失败再关闭,等于把资源先送出去。
1. 令牌从哪来:三条路
- Cookie:同域自动携带,浏览器端最省事。但它会随请求自动发出,因此必须校验
Origin,否则就是跨站 WebSocket 劫持(CSWSH,Cross-Site WebSocket Hijacking)。 - 子协议(
Sec-WebSocket-Protocol):握手请求头,gorilla 用Upgrader.Subprotocols做白名单,连接后conn.Subprotocol()取回。适合放短票据,注意握手头有长度限制,别塞超长 JWT。 - 一次性票据(ticket):先用普通 HTTP(可带
Authorization)换一个 TTL 30~60 秒、用后即焚的短票据,再用?ticket=xxx建连接。URL 里出现的是短命凭证,泄漏面可控,IM 生产环境推荐这条。
2. 三层鉴权,各管一段
- 握手期(
Upgrade之前):确认你是谁(userID / deviceID / tenantID)。校验失败必须直接回 HTTP 401 而不是升级后再关——一旦升级完成,浏览器的onclose拿不到自定义原因,客户端只能看到「连不上」,排查全靠猜。 - 连接级(连接建立后):把身份固化成连接上的只读 principal,后续投递一律以它为准,不再信任客户端自报的 userID。
- 帧级(每次业务操作):订阅房间、发往群组、撤回消息等 scope 逐次校验。只在握手时鉴权、之后收到带
roomID的订阅帧就照单全收,等于把别人的私聊房间开放给任何人。
3. 令牌过期与长连接的矛盾
长连接可以活 12 小时,JWT 却只活 15 分钟。三种处理方式:
- 连接生命周期上限(maxConnLifetime):到期服务端主动发
CloseMessage(4002, "auth expired"),客户端重新握手。简单可靠,代价是一次重连。 - 应用层 refresh 帧:客户端在到期前用新 token 换连接凭证,服务端校验通过后更新 principal 的过期时间,全程不断线,推荐做法。
- 逐帧复查:每次处理业务帧时检查
exp。开销小但介于两者之间,建议连同前一种使用。
// Consume 校验并消费一次性握手票据(HMAC 签名 + TTL + 单次使用)。
func (s *TicketStore) Consume(ctx context.Context, tok string) (string, error) {
parts := strings.SplitN(tok, ".", 2)
if len(parts) != 2 {
return "", errors.New("票据格式非法")
}
buf, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", fmt.Errorf("解码票据失败: %w", err)
}
sig, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", fmt.Errorf("解码签名失败: %w", err)
}
mac := hmac.New(sha256.New, s.secret)
mac.Write(buf)
if !hmac.Equal(sig, mac.Sum(nil)) { // 必须常量时间比较,防止时序攻击
return "", errors.New("票据签名不匹配")
}
var t Ticket
if err := json.Unmarshal(buf, &t); err != nil {
return "", fmt.Errorf("解析票据失败: %w", err)
}
if time.Now().After(t.Exp) {
return "", errors.New("票据已过期")
}
// 一次性:SETNX 保证同一 nonce 只能换一次连接
ok, err := s.redis.SetNX(ctx, "ticket:used:"+t.Nonce, 1, ticketTTL).Result()
if err != nil {
return "", fmt.Errorf("票据去重失败: %w", err)
}
if !ok {
return "", errors.New("票据已被使用")
}
return t.UserID, nil
}
// UpgradeAuth 在升级前完成鉴权,失败直接回 401,不消耗 fd 与 goroutine。
func UpgradeAuth(ts *TicketStore) gin.HandlerFunc {
return func(c *gin.Context) {
userID, err := ts.Consume(c.Request.Context(), c.Query("ticket"))
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid ticket"})
return
}
if !originAllowed(c.Request) { // Origin 白名单,防 CSWSH
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "origin rejected"})
return
}
c.Set(ctxUserIDKey, userID)
c.Next()
}
}
4. 踢下线
同一账号异地登录要踢掉旧设备,封禁要立刻断开。要点是维护 userID → conn 的连接注册表,并在多实例部署下用 Redis 广播(本机 Hub 只能踢本机连接):
// Kick 踢掉某用户在本实例的全部连接,返回被踢数量。
func (h *Hub) Kick(userID, reason string) int {
conns := h.registry.byUser(userID)
for _, c := range conns {
// 先发带原因的关闭帧再关闭;直接 Close,对端只能看到 1006 异常关闭
_ = c.conn.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(codeKicked, reason), time.Now().Add(writeWait))
c.close() // 由连接自身注销注册表,避免并发删 map
}
return len(conns)
}
关闭码要用私有区间 4000–4999 并语义化,客户端据此决定是否重连:
| 关闭码 | 含义 | 客户端行为 |
|---|---|---|
| 4001 | 被其他设备踢下线 | 不重连,提示「账号在其他设备登录」 |
| 4002 | 令牌过期 | 静默 refresh 后重连一次 |
| 4003 | 权限被撤 | 不重连,回到登录页 |
5. 参数取值建议
| 参数 | 建议值 | 说明 |
|---|---|---|
| ticket TTL | 30~60 s | 只够完成一次握手;过短会让弱网用户失败 |
| 访问令牌 TTL | 15 min | JWT 常规值,配合 refresh 帧使用 |
| maxConnLifetime | 12 h | 兜底强制重握手,也便于滚动发布 |
| 单用户连接数上限 | 3~5 | 超出时踢最旧的连接 |
6. 常见坑与排查
- token 进 URL 后泄漏进日志:Nginx 用
log_format过滤ticket=,或干脆只放一次性票据。 CheckOrigin恒返回 true:等于关掉浏览器侧唯一的内置防线,必配白名单。- 签名用
==比较:会被时序攻击逐字节猜出签名,必须hmac.Equal。 - JWT 不校验
alg:alg=none与 HS/RS 混淆是经典绕过手段,库的ParseWithClaims要显式限定算法。 - 封禁状态只缓存在单机内存:多实例下封禁不生效,需共享存储 + 主动踢。
- 踢下线只在本实例生效:跨实例要靠 Redis Pub/Sub 让每台机器各自踢本机连接。
- 关闭码用错:发 1000 会让客户端「正常关闭、不重连」;1006 是保留码,不允许主动发送。
排查看四个数:握手 401 比例、被踢次数、同一 userID 的并发连接数(大于上限说明注册表有泄漏)、票据重复使用告警(出现即有人在重放)。压测鉴权路径时用 wrk 直接打升级接口,确认未通过鉴权的请求不会留下 goroutine。
小结:鉴权要在 Upgrade 之前完成(一次性票据 + Origin 白名单),身份在连接上固化为只读 principal,权限按帧校验,过期用 refresh 帧或 maxConnLifetime 兜底,踢下线靠连接注册表加私有关闭码并跨实例广播——五件事都做到,长连接的身份边界才算真正立住。