docs: 会话安全方案文档 + 本地测试脚本

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-19 07:34:07 +08:00
parent 32bd64d676
commit 51c81fd943
2 changed files with 161 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
# 会话安全加固 — 方案设计
> 本文档覆盖登录会话(`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` 均不经请求体传入,无越权面。
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# local_test.sh — 本地编译一份「指向线上后端」的 macOS app 并启动,用于发版前自测。
#
# 用法:
# sh scripts/local_test.sh # 编译 + 启动
# sh scripts/local_test.sh --no-open # 只编译不启动
#
# 后端固定指向生产 https://jiu.51yanmei.com(与 CI compile-macos.sh 一致)。
set -euo pipefail
export PATH="/opt/homebrew/bin:$PATH"
# 版本号:取最新 client-v* tag 的 patch+1,纯展示用,取不到则回退 0.0.0-dev。
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
LATEST_TAG="$(git describe --tags --match 'client-v*' --abbrev=0 2>/dev/null || true)"
if [ -n "$LATEST_TAG" ]; then
BASE_VER="${LATEST_TAG#client-v}"
MAJOR_MINOR="${BASE_VER%.*}"
PATCH="${BASE_VER##*.}"
VER="${MAJOR_MINOR}.$((PATCH + 1))"
else
VER="0.0.0-dev"
fi
APP="client/build/macos/Build/Products/Release/jiu_client.app"
echo "==> 编译 macOS app(线上后端,版本 v${VER}"
cd client
flutter build macos --release \
"--dart-define=BASE_URL=https://jiu.51yanmei.com" \
"--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \
"--dart-define=APP_VERSION=v${VER}"
cd "$REPO_ROOT"
echo "==> 产物:$APP"
if [ "${1:-}" = "--no-open" ]; then
echo "==> 跳过启动(--no-open"
else
echo "==> 前台启动 app(终端附着日志,退出 app 才返回;Ctrl-C 可终止)"
exec "$APP/Contents/MacOS/jiu_client"
fi