登录与鉴权:令牌、设备指纹与多端策略
IM 的鉴权有两次:一次是登录接口换令牌,一次是建立长连接时的 auth 帧。前者决定"你是谁",后者决定"这一端能不能接收消息"。两者出错都表现为"消息收不到",所以本章把令牌分层、设备指纹、多端互踢与撤销四件事一次讲透。
登录与建连时序
客户端 登录服务 鉴权/接入层 存储
│ POST /login(账号+凭证)───▶│ │ │
│ │─ 校验凭证 + 风控 ─────┼───────────────▶│
│ │─ 登记设备(uid,did) ───┼───────────────▶│
│◀─ access(2h)+refresh(30d)│ │ │
│ TCP/TLS 建连 ─────────────┼─────────────────────▶│ │
│ auth 帧(token,did,dt) ────┼─────────────────────▶│ │
│ │ │─ 验签+查设备状态│
│ │ │─ 写路由 Bind ──▶│
│◀─ auth_ack(uid,设备列表)──┼──────────────────────│ │
│◀─ kick(被同类型新端顶掉)─┼──────────────────────│ │
顺序不可颠倒:先登录拿令牌,再用令牌建连;未鉴权连接除 auth 与心跳外不允许发任何命令,且 10s 内不发 auth 帧即断开,防止白占连接。
令牌分层
一个令牌解决不了所有问题:接口要短、长连接要长、被盗要能立刻失效。拆成三种:
| 令牌 | 有效期 | 存储位置 | 用途 | 撤销方式 |
|---|---|---|---|---|
| access | 2h | 客户端内存 | 接口鉴权、建连 auth 帧 | 短有效期 + 高危场景黑名单 |
| refresh | 30d | Keychain / 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 且键名脱敏,时钟偏移容忍与刷新单飞属必配项。