登录与鉴权:令牌、设备指纹与多端策略

IM 的鉴权有两次:一次是登录接口换令牌,一次是建立长连接时的 auth 帧。前者决定"你是谁",后者决定"这一端能不能接收消息"。两者出错都表现为"消息收不到",所以本章把令牌分层、设备指纹、多端互踢与撤销四件事一次讲透。

登录与建连时序

客户端                     登录服务              鉴权/接入层           存储
  │ POST /login(账号+凭证)───▶│                      │                │
  │                          │─ 校验凭证 + 风控 ─────┼───────────────▶│
  │                          │─ 登记设备(uid,did) ───┼───────────────▶│
  │◀─ access(2h)+refresh(30d)│                      │                │
  │ TCP/TLS 建连 ─────────────┼─────────────────────▶│                │
  │ auth 帧(token,did,dt) ────┼─────────────────────▶│                │
  │                          │                      │─ 验签+查设备状态│
  │                          │                      │─ 写路由 Bind ──▶│
  │◀─ auth_ack(uid,设备列表)──┼──────────────────────│                │
  │◀─ kick(被同类型新端顶掉)─┼──────────────────────│                │

顺序不可颠倒:先登录拿令牌,再用令牌建连;未鉴权连接除 auth 与心跳外不允许发任何命令,且 10s 内不发 auth 帧即断开,防止白占连接。

令牌分层

一个令牌解决不了所有问题:接口要短、长连接要长、被盗要能立刻失效。拆成三种:

令牌有效期存储位置用途撤销方式
access2h客户端内存接口鉴权、建连 auth 帧短有效期 + 高危场景黑名单
refresh30dKeychain / Keystore静默换取新 access服务端只存 SHA-256,可即时吊销
// JWT claims 只放鉴权必需信息,业务数据一律回查,避免令牌泄漏后信息外溢
type Claims struct {
	UID        int64  `json:"sub"`
	DeviceID   string `json:"did"` // 设备指纹,见下节
	DeviceType int32  `json:"dt"`  // 1=iOS 2=Android 3=Web 4=Desktop
	TokenVer   int32  `json:"ver"` // 令牌版本,全局踢人时 +1
	jwt.RegisteredClaims           // iss / iat / exp / jti
}

func VerifyAccess(tokenStr string, key []byte) (*Claims, error) {
	claims := &Claims{}
	// 显式限定签名算法,禁止 alg=none,否则等于没有鉴权
	token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) {
		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, ErrUnexpectedSigningMethod
		}
		return key, nil
	}, jwt.WithLeeway(60*time.Second)) // 容忍 60s 时钟偏移
	if err != nil || !token.Valid || claims.UID == 0 || claims.DeviceID == "" {
		return nil, ErrTokenInvalid
	}
	return claims, nil
}

关键纪律:验签之后必须校验 did 与服务端登记的"设备是否有效"。只验签不看设备,等于令牌一泄漏就能在任意设备登录,多端策略形同虚设。

设备指纹

设备指纹(device fingerprint)用于区分"同一账号的不同端",而不是"同一台物理设备":

did = SHA256(platform + "|" + installID)[:32]
platform :ios / android / web / windows / macos
installID:首次启动生成 UUIDv4 存安全存储(iOS Keychain / Android Keystore / Web localStorage)
不使用 IMEI、MAC、序列号;重装后 did 变化属预期:它标识"这次安装",不标识"这台机器"

服务端把设备作为一等实体保存,多端上限、互踢与登录审计都基于这张表:

CREATE TABLE `user_device` (
  `id`            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `uid`           BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
  `device_id`     VARCHAR(64)  NOT NULL COMMENT '设备指纹',
  `device_type`   TINYINT      NOT NULL COMMENT '1=iOS 2=Android 3=Web 4=Desktop',
  `refresh_hash`  CHAR(64)     NOT NULL DEFAULT '' COMMENT '当前有效 refresh 的 SHA-256',
  `status`        TINYINT      NOT NULL DEFAULT 1 COMMENT '1=有效 0=已剔除',
  `last_login_at` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_uid_device` (`uid`, `device_id`),
  KEY `idx_uid_status` (`uid`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户设备表';

按 uid 分库分表(第 23 章),因为查询形态只有"某用户的设备列表"这一种。

多端登录策略

策略实现复杂度体验资源占用适用
单端在线(新登录踢掉全部旧端)低差,共享账号会互相顶掉最低安全敏感、按席位收费的产品
同类型互踢(只踢同 device_type)中好,符合直觉中主流 IM 的默认选择
多端上限 + 踢最旧(建议上限 5)中高最好较高(路由与同步按端维度)大厂主流方案

互踢的实现要点是踢不掉也要记仇:目标接入机可能刚好不可达,此时必须把 did 写进撤销集合,下次该设备再建连时直接拒绝,实现最终一致。

im:auth:refresh:{uid}:{did}  → refresh 的 SHA-256,TTL=30d,轮换时覆盖
im:auth:revoke:{jti}         → 1,TTL 取 access 剩余有效期(仅高危场景写黑名单)
im:auth:revoked_did:{uid}    → SET,成员为被踢 did,建连时校验,TTL=30d
im:auth:fail:{sha1(account)} → 登录失败计数,INCR + 300s 过期,≥5 次要求验证码

键名里不要出现手机号等明文标识,一律 sha1(account) 脱敏,否则 Redis 就成了第二个用户信息库。

建连鉴权实现

// 任何一步失败都要回明确错误码,让客户端知道该"重登"还是"重试"
func (s *AuthService) AuthConn(ctx context.Context, req *AuthReq) (*AuthResp, error) {
	claims, err := VerifyAccess(req.Token, s.signKey)
	if err != nil {
		return nil, ErrCodeTokenInvalid // 2xxx:应走 refresh 或重新登录
	}
	if claims.DeviceID != req.DeviceID { // 令牌与设备不匹配,疑似盗用
		return nil, ErrCodeDeviceMismatch
	}
	dev, err := s.deviceRepo.Get(ctx, claims.UID, req.DeviceID)
	if err != nil || dev.Status != DeviceStatusValid || s.isRevoked(ctx, claims.UID, req.DeviceID) {
		return nil, ErrCodeDeviceRevoked // 设备已剔除或被踢,拒绝建连
	}
	return &AuthResp{UID: claims.UID, DeviceID: claims.DeviceID}, nil
}

工程注意点

  • 长连接建立后不再逐帧验签:每帧验签会吃掉大量 CPU,正确做法是"建连验一次 + 连接内信任 + 需撤销时通过 kick 下发";kick 帧必须带原因码(同端登录、风控、版本过低),客户端据此展示不同文案,而不是统一弹"您已在其他设备登录"。
  • refresh 刷新必须单飞:并发请求同时 401 会触发多次刷新,而 refresh 轮换是"用一次即失效",互相作废会把用户踢下线;客户端加单飞锁,服务端对同一 did 的刷新留 5s 幂等窗口。
  • 鉴权失败不要重试风暴:令牌无效时客户端刷新一次令牌,仍失败则停止重连并引导重新登录;服务端对同一 did 连续 10 次失败后按 60s 拒绝建连。

小结:登录用 access(2h)+ refresh(30d)双层令牌,refresh 只存哈希且用一次即轮换;设备指纹由 installID 派生、不使用硬件唯一标识,并在 user_device 表按 uid 分片持久化;长连接用 auth 帧一次鉴权,验签之后必须比对 did 与设备状态;多端策略选"同类型互踢"或"多端上限 + 踢最旧",踢不掉时写撤销集合保证最终一致;撤销、失败计数放 Redis 且键名脱敏,时钟偏移容忍与刷新单飞属必配项。

笔记加载中…