长连接鉴权:握手期令牌、连接级权限与踢下线

浏览器的 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 TTL30~60 s只够完成一次握手;过短会让弱网用户失败
访问令牌 TTL15 minJWT 常规值,配合 refresh 帧使用
maxConnLifetime12 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 兜底,踢下线靠连接注册表加私有关闭码并跨实例广播——五件事都做到,长连接的身份边界才算真正立住。

笔记加载中…