Files
jiu/docs/architecture/session-security.md
2026-06-19 07:34:07 +08:00

6.7 KiB
Raw Permalink Blame History

会话安全加固 — 方案设计

本文档覆盖登录会话(user_sessions)的安全与运维要素:refresh token 轮换/盗用检测、改密与禁用即时下线、吊销审计、保留清理、失败登录落库。纯后端实现,老 token 平滑过渡(发版不强制全员重登)。


1. 背景与目标

登录会话已支持登出/踢人/在线监控,但对照成熟 session 实现仍缺一批安全与运维要素。本次补齐 7 项:

  1. Refresh token 不轮换、无盗用检测 — 续期复用同一 sid,库里不存 token 凭据,泄露后到期前一直可用且无感知。
  2. 改密码不吊销会话 — 旧设备 token 仍有效。
  3. 禁用用户不即时下线 — 中间件每请求只查 session.revoked_at,不查 user.is_active
  4. revoked_by 缺失 — 无法审计谁吊销了会话。
  5. 无清理/保留策略 — 撤销/过期会话行永不删,表无限膨胀、IP/UA 永久留存。
  6. last_seen_at 的 gorm tag 语义不清 — 易被后人误改成 autoUpdateTime 引入回归。
  7. 失败登录不落库 — 仅内存限流计数,重启清零,无审计/风控留痕。

目标:不强制现网用户重登的前提下堵上缺口,并补齐后端自动化测试。


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 写入 jtiaccess token 不需要。
  • Login:生成初始 jti=uuid,建会话写入 refresh_jti,下发 refresh token 带该 jti。
  • RefreshTokens(事务 + FOR UPDATE 锁会话行):
    1. 锁定会话,未找到/已撤销 → ErrSessionRevoked
    2. 重用检测sess.RefreshJTI != "" && claims.ID != sess.RefreshJTI → 旧/被取代的 refresh token 重放 = 盗用信号 → 吊销整条会话(reason=reuse)并返回 ErrSessionRevoked
    3. 校验用户存在/启用/授权未锁。
    4. 轮换newJTI=uuid,更新 refresh_jti + last_seen_at,下发新 pair。
  • 向后兼容:老 token claims.ID=="" 且老会话 refresh_jti=="" → 合法首刷,直接采纳新 jti 不报重用(避免发版即把所有人踢下线)。

实现细节:重用检测的「吊销」必须在事务执行 —— 事务内返回非 nil error 会回滚包括吊销在内的全部写入,故用内部 sentinel errRefreshReuse 从事务返回(无写入、安全回滚),再在事务外 commit 吊销。


4. 改密 / 禁用即时下线(#2 #3)

  • 改密user.go ResetPassword):更新 password_hash 后吊销该用户全部活跃会话(reason=pwd_reset, revoked_by=操作人)。
  • 禁用user.go Updateis_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_byhandler 传 middleware.GetUserID(c)
  • 并发踢人(kicked)、自助登出(logout)、盗用(reuse)无明确他方操作人 → revoked_by 留 NULL(系统)。

6. 保留清理(#5

  • config.Session.RetentionDays(默认 90retention_days <= 0 关闭清理)。
  • service.StartSessionCleanup(db, retentionDays)goroutine 启动即跑一次 + 24h ticker 周期跑。每次 cutoff = now - RetentionDays
    • user_sessionsrevoked_at < cutoffrefresh_exp_at < cutoff
    • login_attemptscreated_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 轮换/重用/向后兼容、#4 revoked_by、#5 清理、#7 失败落库。
  • internal/handler/session_security_test.go#2 改密吊销(原 token 401 SESSION_REVOKED、审计字段)、#3 禁用吊销 + 中间件对直接改库的 USER_DISABLED 兜底。

10. 影响边界 / 风险

  • 平滑发版:老 access/refresh token(无 jti)走兼容分支,不会因部署被批量登出。
  • 每请求成本:中间件由「单查 session」变「session JOIN users」,仍为一次查询。
  • 写放大:失败登录落库受内存限流封顶 + 保留期清理,生产可控。
  • 多租户/权限不变revoked_by/refresh_jti/login_attempts 均不经请求体传入,无越权面。