liuyun b807cc7df8
Some checks are pending
CI / backend (push) Waiting to run
CI / frontend (push) Waiting to run
docs(spec): 归档 online-session-management + 同步 session 主 spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 21:38:24 +08:00

64 lines
5.5 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.

## Context
当前「会话」= `sys_refresh_token` 一行:`jti` 即 refresh token 明文(随机 UUID),`tokenHash` = sha256(jti),`id` 为 uuid v7。access JWT 载荷为 `{sub, tenantCode, username, ver}`(`ver` = tokenVersion)。每次请求 `JwtAuthGuard` → `checkUserActive`(Redis 缓存 `{status, tokenVersion}`,校验 status=enabled 且 ver 一致)。`issue()` 在登录与刷新时都新建 refresh token 行(登录带 ip/userAgent,刷新不带),刷新时旧行置 `revokedAt`(轮换)。「踢下线」目前只有 `invalidateSessions`:tokenVersion+1 + 删全部 refresh token + 清 `perm:`/`user:` 缓存。动机见 proposal.md - Why。
## Goals / Non-Goals
**Goals:**
- access token 携带稳定会话标识,支持「单个会话精确强退」与「按用户踢全部」。
- 会话列表可读、可筛选、受数据权限约束。
- 独立于 `status` 的账号锁定/解锁闭环。
**Non-Goals:**
- 设备指纹 / 多因素登录 / SSO。
- 会话自动续期策略变更(仍为 access 2h + refresh 7d 轮换)。
- 密码策略与首登强制改密(另立 change)。
## Decisions
### D1 — 会话标识 sid 用独立列,刷新延续同一 sid
`RefreshToken` 新增 `sid`(uuid v7,加索引)。登录时生成新 sid;刷新时新行**沿用旧 sid**,并向前携带 `loginAt/ip/userAgent`、延长 `expireAt`。access token 载荷新增 `sid`。
- **为什么不用 `id` 当 sid**:刷新轮换会新建行,`id` 随之变化,会话连续性丢失(列表会显示「最近刷新时间」而非「原始登录时间」,且 ip/UA 丢失)。
- **备选**:`issue()` 携带全部会话元信息每次重建——被 D1 取代,因为列表需要原始登录信息。
### D2 — 单会话强退 = 吊销 refresh + sid 黑名单
`POST /sessions/:sid/kick`:按 sid 吊销活跃 refresh token(置 `revokedAt`),并写 Redis 黑名单 `sessionRevoked:{sid}=1`(TTL = ACCESS_TTL,覆盖在飞 access token 剩余寿命)。`JwtAuthGuard` 在 `checkUserActive` 后追加一次 sid 黑名单判定,命中即 401。
- **为什么黑名单而非只吊销 refresh**:access token 无状态,单靠删 refresh 无法立即杀在飞 access token。
- **备选**:把 sid 塞进 per-user 的 user-state 缓存——不可行,user-state 是 per-user,无法承载 per-session 吊销。
### D3 — 踢全部复用 tokenVersion
`POST /users/:id/kick-sessions` 复用现有 `invalidateSessions`。access token 带 `ver`,tokenVersion 一 bump,全部在飞 access token 下次请求即 401,无需逐会话黑名单。
### D4 — 锁定 = 独立 `lockedAt/lockReason` 字段(用户已拍板)
`User` 新增 `lockedAt`(DateTime?,`locked_at`)与 `lockReason`(String?,`lock_reason`)。`status` 语义不变(enabled/disabled 手动启停)。锁定判定 = `lockedAt != null`。
- **登录/刷新/守卫三处统一拒绝锁定**:`loginTenant`、`refresh`、`checkUserActive`(user-state 缓存对象扩展为 `{status, tokenVersion, lockedAt}`,`!lockedAt` 才放行)。
- **锁定动作** `POST /users/:id/lock`(body `{reason?}`):置 `lockedAt=now/lockReason` + 调 `invalidateSessions`(踢全部,顺带清 `user:` 缓存使守卫立即生效)。
- **解锁动作** `POST /users/:id/unlock`:清 `lockedAt/lockReason` + 清 Redis 登录失败计数(`loginFail:{ip}:{username}` 与 `loginFail:global:{username}`)。
- **为什么复用 status='locked' 被否**:手动禁用 ≠ 安全锁定,混用会让「解锁」误启用被手动禁用的账号。
### D5 — 会话列表读 refresh_token,按数据权限过滤
`GET /sessions` 直接查 `refreshToken`(`revokedAt=null && expireAt>now`),join user 取 username/nickname。数据权限复用 users 域既有的可见范围计算:先得当前上下文可见 `userId` 集合,再 `userId in (...)`;超管免过滤。
### D6 — 资源注册(RBAC 锚点)
`tenant-template` 新增:菜单 `session`(在线会话,parent=monitor,/monitor/session);接口 `session:list`(GET /api/v1/sessions)、`session:kick`(POST /api/v1/sessions/:sid/kick)、`user:kickSessions`(POST /api/v1/users/:id/kick-sessions)、`user:lock`(POST /api/v1/users/:id/lock)、`user:unlock`(POST /api/v1/users/:id/unlock)。`Permissions` 枚举同步新增这 5 个权限码。
## Risks / Trade-offs
- **[Redis 重启丢 sid 黑名单 → 被强退的在飞 token 短暂复活]** → 黑名单 TTL 仅 access 剩余时长;refresh 已吊销无法续期,最坏 2h 内复活,与 user-state 缓存同语义,可接受。
- **[守卫每请求多一次 Redis 判定]** → 仅当 token 带 `sid` 时查询单 key,开销可忽略;`ver` 校验仍走既有 user-state 缓存。
- **[存量租户缺少新资源]** → Migration 阶段对全部租户重跑 `initTenant`(幂等 upsert)补齐 session 菜单/接口权限。
- **[锁定与停用双语义遗漏路径]** → 在 loginTenant / refresh / checkUserActive 三处统一加 `lockedAt` 判定,并在解锁时同步清登录失败计数,避免「解锁后仍被临时阈值卡住」。
## Migration Plan
1. Prisma migration:`sys_user` 加 `locked_at`、`lock_reason`;`sys_refresh_token` 加 `sid`(可空、加索引;旧行 sid 为空,仅新会话写入)。
2. 代码上线(auth guard 校验 sid 黑名单 + lockedAt)。
3. 存量租户资源补齐:遍历所有租户重跑 `initTenant`,使超管/租户超管获得 session 菜单与 lock/unlock 接口权限。
4. 回滚:新列为可空、向后兼容;回滚代码即可,无需回滚数据。
## Open Questions
无。