登录鉴权与设备管理落地
本章目标:串起「密码登录 → 签发令牌 → 登记设备 → HTTP 与网关双入口校验 → 踢下线立刻生效」这条链路,每一步都能用命令验证。
1. 表结构的最小集
CREATE TABLE user (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(64) NOT NULL,
password_hash VARCHAR(72) NOT NULL, status TINYINT NOT NULL DEFAULT 1, -- bcrypt 固定 60 字节;status 1 正常 0 禁用
PRIMARY KEY (id), UNIQUE KEY uk_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE user_device (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL,
device_id VARCHAR(64) NOT NULL, platform VARCHAR(16) NOT NULL DEFAULT 'web',
push_token VARCHAR(255) NOT NULL DEFAULT '', last_active BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (id), UNIQUE KEY uk_user_device (user_id, device_id) -- 唯一索引保证同设备只有一行
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2. 令牌与设备版本号
- Access Token(访问令牌):JWT,2 小时过期,声明带
uid、did(设备 ID)、ver(设备版本号)。 - Refresh Token(刷新令牌):32 字节随机串,30 天,
im:rt:{uid}:{did}只存sha256哈希。 - 设备版本号:Redis 键
im:devver:{uid}:{did}。登录即 INCR(同设备重登踢掉旧会话),踢下线也 INCR(旧令牌下次校验即失效)。
3. 登录与签发
// logic/auth.go
type Claims struct {
UID uint64 `json:"uid"`
DeviceID string `json:"did"`
Ver int64 `json:"ver"`
jwt.RegisteredClaims
}
// Login = 查库比对 bcrypt → 抬设备版本号 → 签发令牌
func (s *AuthService) Login(ctx context.Context, in LoginInput) (*LoginResult, error) {
u, err := s.users.ByUsername(ctx, in.Username)
if err != nil && !errors.Is(err, store.ErrNotFound) {
return nil, fmt.Errorf("query user: %w", err)
}
// 用户不存在与密码错误返回同一个错误,避免账号枚举
if u == nil || bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(in.Password)) != nil {
return nil, ErrInvalidCredential
}
ver, err := s.rdb.Incr(ctx, deviceVerKey(u.ID, in.DeviceID)).Result() // 登录即抬版本号
if err != nil {
return nil, fmt.Errorf("bump device ver: %w", err)
}
access, err := s.signToken(u.ID, in.DeviceID, ver) // HS256,2 小时过期
if err != nil {
return nil, err
}
return &LoginResult{UID: u.ID, AccessToken: access, ExpiresIn: 7200}, nil
}
4. 校验与踢下线
// Verify 是 HTTP 中间件与网关握手共用的入口:验签 + 过期 + 设备版本号
func (s *AuthService) Verify(ctx context.Context, raw string) (*Claims, error) {
claims := &Claims{}
if _, err := jwt.ParseWithClaims(raw, claims, func(*jwt.Token) (any, error) {
return s.secret, nil
}, jwt.WithValidMethods([]string{"HS256"})); err != nil { // 锁定算法,防算法混淆
return nil, ErrUnauthorized
}
cur, err := s.rdb.Get(ctx, deviceVerKey(claims.UID, claims.DeviceID)).Int64()
if err != nil {
return nil, ErrUnauthorized // 版本号取不到(被踢或已过期)或 Redis 故障,一律不认
}
if cur != claims.Ver {
return nil, ErrUnauthorized
}
return claims, nil
}
// Kick 踢下线:抬版本号让旧令牌失效,再通知网关断开现有连接
func (s *AuthService) Kick(ctx context.Context, uid uint64, deviceID string) error {
if err := s.rdb.Incr(ctx, deviceVerKey(uid, deviceID)).Err(); err != nil {
return fmt.Errorf("bump device ver: %w", err)
}
// 抬版本号 + 断连接缺一不可:只抬版本号,旧连接还能继续发消息
// 网关订阅 im:kick:{uid},payload 就是 device_id
if err := s.rdb.Publish(ctx, "im:kick:"+strconv.FormatUint(uid, 10), []byte(deviceID)).Err(); err != nil {
return fmt.Errorf("publish kick: %w", err)
}
return nil
}
5. 网关握手鉴权
浏览器 WebSocket 不能自定义请求头,令牌从子协议里取(服务端要把该子协议回显,否则部分浏览器判握手失败),查询参数只作非浏览器兜底。
// logic/gatewayauth.go:实现第 02 章的 gateway.Authenticator
func GatewayAuthenticator(auth *AuthService) gateway.Authenticator {
return func(r *http.Request) (uint64, string, error) {
claims, err := auth.Verify(r.Context(), tokenFromSubProtocol(r))
if err != nil {
return 0, "", err
}
return claims.UID, claims.DeviceID, nil
}
}
HTTP 路由三条:POST /api/v1/login 免令牌,GET /api/v1/devices 与 POST /api/v1/devices/kick 走鉴权中间件,通过后 c.Set("uid", claims.UID)。UID 一律取令牌里的值,绝不接受客户端自己上报的 uid。
6. 验证方式
curl -s -X POST http://127.0.0.1:8081/api/v1/login -H 'Content-Type: application/json' -d '{"username":"alice","password":"alice123","device_id":"web-1","platform":"web"}'
# 输出:{"uid":1,"access_token":"eyJ...","expires_in":7200};同设备再登一次,旧令牌立刻 401
curl -s -X POST http://127.0.0.1:8081/api/v1/devices/kick -H "Authorization: Bearer <新令牌>" -d '{"device_id":"web-1"}'
# 输出:{"kicked":1},网关日志同时出现 connection released
常见坑
| 坑 | 后果 | 做法 |
|---|---|---|
| 令牌放 URL 查询参数 | 被 access 日志与浏览器历史留存 | 优先子协议,查询参数只作非浏览器兜底 |
| 只抬版本号不关连接 | 用户仍能用旧连接发消息 | 校验拦新请求 + im:kick 断旧连接 |
| Refresh Token 存原文 | 泄露即可长期冒充 | 存哈希 + 30 天 TTL + 换新时轮换 |
小结:鉴权的核心是「令牌携带身份 + Redis 版本号管失效」——登录查库比对 bcrypt、抬设备版本号、签发双令牌;HTTP 中间件与网关握手共用同一个 Verify;踢下线靠抬版本号作废旧令牌,再用 im:kick 断掉现有长连接。第 05 章开始,连接上的业务跑到逻辑层去处理。