liuyun a8e8ef8606
Some checks are pending
CI / backend (push) Waiting to run
CI / frontend (push) Waiting to run
feat(session): 在线会话强踢 + 账号锁定/解锁(子模块 + openspec change)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 21:36:24 +08:00

5.5 KiB
Raw Blame History

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

无。