5.5 KiB
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
- Prisma migration:
sys_user加locked_at、lock_reason;sys_refresh_token加sid(可空、加索引;旧行 sid 为空,仅新会话写入)。 - 代码上线(auth guard 校验 sid 黑名单 + lockedAt)。
- 存量租户资源补齐:遍历所有租户重跑
initTenant,使超管/租户超管获得 session 菜单与 lock/unlock 接口权限。 - 回滚:新列为可空、向后兼容;回滚代码即可,无需回滚数据。
Open Questions
无。