Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6.7 KiB
会话安全加固 — 方案设计
本文档覆盖登录会话(
user_sessions)的安全与运维要素:refresh token 轮换/盗用检测、改密与禁用即时下线、吊销审计、保留清理、失败登录落库。纯后端实现,老 token 平滑过渡(发版不强制全员重登)。
1. 背景与目标
登录会话已支持登出/踢人/在线监控,但对照成熟 session 实现仍缺一批安全与运维要素。本次补齐 7 项:
- Refresh token 不轮换、无盗用检测 — 续期复用同一
sid,库里不存 token 凭据,泄露后到期前一直可用且无感知。 - 改密码不吊销会话 — 旧设备 token 仍有效。
- 禁用用户不即时下线 — 中间件每请求只查
session.revoked_at,不查user.is_active。 revoked_by缺失 — 无法审计谁吊销了会话。- 无清理/保留策略 — 撤销/过期会话行永不删,表无限膨胀、IP/UA 永久留存。
last_seen_at的 gorm tag 语义不清 — 易被后人误改成autoUpdateTime引入回归。- 失败登录不落库 — 仅内存限流计数,重启清零,无审计/风控留痕。
目标:不强制现网用户重登的前提下堵上缺口,并补齐后端自动化测试。
2. 数据模型变更
GORM AutoMigrate 只增不删,新增列/表生产安全;schema.sql 与 sqlite 测试建表手工同步。
user_sessions 新增两列:
| 列 | 类型 | 含义 |
|---|---|---|
refresh_jti |
VARCHAR(64) |
当前有效 refresh token 的 jti(轮换 + 重用检测) |
revoked_by |
BIGINT UNSIGNED |
吊销操作人 user_id(系统/自助吊销为 NULL) |
revoked_reason 新增取值:reuse(盗用检测)、pwd_reset(改密)、disabled(禁用)。
新增表 login_attempts(失败登录落库):
id, shop_code VARCHAR(64), username VARCHAR(50), ip VARCHAR(64),
user_agent VARCHAR(512), success TINYINT(1), reason VARCHAR(40), created_at
-- 索引: (username, created_at), (ip, created_at)
model.LoginAttempt 已加入 main.go autoMigrate(...)。
3. Refresh token 轮换 + 盗用检测(#1)
采用 jti 轮换 + token family 重用检测(存 jti 而非整 token hash —— JWT 签名已防伪造,jti 足以判定「是否已被取代」,更轻量):
Claims内嵌jwt.RegisteredClaims,其ID字段即 jti。仅 refresh token 写入 jti,access token 不需要。- Login:生成初始
jti=uuid,建会话写入refresh_jti,下发 refresh token 带该 jti。 - RefreshTokens(事务 +
FOR UPDATE锁会话行):- 锁定会话,未找到/已撤销 →
ErrSessionRevoked。 - 重用检测:
sess.RefreshJTI != "" && claims.ID != sess.RefreshJTI→ 旧/被取代的 refresh token 重放 = 盗用信号 → 吊销整条会话(reason=reuse)并返回ErrSessionRevoked。 - 校验用户存在/启用/授权未锁。
- 轮换:
newJTI=uuid,更新refresh_jti+last_seen_at,下发新 pair。
- 锁定会话,未找到/已撤销 →
- 向后兼容:老 token
claims.ID==""且老会话refresh_jti==""→ 合法首刷,直接采纳新 jti 不报重用(避免发版即把所有人踢下线)。
实现细节:重用检测的「吊销」必须在事务外执行 —— 事务内返回非 nil error 会回滚包括吊销在内的全部写入,故用内部 sentinel
errRefreshReuse从事务返回(无写入、安全回滚),再在事务外 commit 吊销。
4. 改密 / 禁用即时下线(#2 #3)
- 改密(
user.goResetPassword):更新password_hash后吊销该用户全部活跃会话(reason=pwd_reset,revoked_by=操作人)。 - 禁用(
user.goUpdate,is_active=false):吊销该用户全部活跃会话(reason=disabled,revoked_by=操作人)—— 立即生效、零每请求开销。 - 上述吊销复用 service 层包级函数
RevokeUserSessions(db, shopID, userID, byUserID, reason)。 - 兜底(防直接改库):中间件
JWT会话校验由「单查 session」改为「session JOIN users」,顺带取is_active/deleted_at;禁用或删除即返回401 {code:"USER_DISABLED"}。仍是每请求一次查询(sid唯一键覆盖),无额外往返。 RefreshTokens已校验is_active,与中间件构成双保险。
5. 吊销审计(#4)
ForceLogout(shopID, sessionID, byUserID)加操作人参数,写入revoked_by;handler 传middleware.GetUserID(c)。- 并发踢人(
kicked)、自助登出(logout)、盗用(reuse)无明确他方操作人 →revoked_by留 NULL(系统)。
6. 保留清理(#5)
config.Session.RetentionDays(默认 90,retention_days <= 0关闭清理)。service.StartSessionCleanup(db, retentionDays):goroutine 启动即跑一次 + 24h ticker 周期跑。每次cutoff = now - RetentionDays:- 删
user_sessions:revoked_at < cutoff或refresh_exp_at < cutoff。 - 删
login_attempts:created_at < cutoff。
- 删
7. last_seen_at tag 加固(#6)
该字段全程手工维护(Login 建行赋值、心跳/refresh 显式 Update)。autoCreateTime 正好满足「建行给默认、之后不被 ORM 自动改」;若改 autoUpdateTime 反而会在 revoke/cleanup 等 Updates 时错误地把已撤销会话刷成「刚活跃」。故 #6 的正确处理是防回归而非改行为:保留 autoCreateTime + 补警示注释。
8. 失败登录落库(#7)
- helper
recordLoginAttempt(shopCode, username, dev, success, reason)写一行login_attempts。 Login各失败分支各记一条:invalid_shop/invalid_user/inactive/bad_password/locked/platform_not_allowed。成功路径不记(已由user_sessions+last_login_at覆盖)。- 写库失败仅
log.Printf不阻断登录。内存限流器(loginLim)保留,锁定后写入自然受限,旧行由 #5 清理。
9. 测试覆盖
internal/service/session_hardening_test.go:#1 轮换/重用/向后兼容、#4revoked_by、#5 清理、#7 失败落库。internal/handler/session_security_test.go:#2 改密吊销(原 token 401SESSION_REVOKED、审计字段)、#3 禁用吊销 + 中间件对直接改库的USER_DISABLED兜底。
10. 影响边界 / 风险
- 平滑发版:老 access/refresh token(无 jti)走兼容分支,不会因部署被批量登出。
- 每请求成本:中间件由「单查 session」变「session JOIN users」,仍为一次查询。
- 写放大:失败登录落库受内存限流封顶 + 保留期清理,生产可控。
- 多租户/权限不变;
revoked_by/refresh_jti/login_attempts均不经请求体传入,无越权面。