51c81fd943
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
118 lines
6.7 KiB
Markdown
118 lines
6.7 KiB
Markdown
# 会话安全加固 — 方案设计
|
||
|
||
> 本文档覆盖登录会话(`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** 写入 jti,access 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` 均不经请求体传入,无越权面。
|