## 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 无。