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

118 lines
6.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 会话安全加固 — 方案设计
> 本文档覆盖登录会话(`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`(失败登录落库):
```sql
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` `Update``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 轮换/重用/向后兼容、#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` 均不经请求体传入,无越权面。