diff --git a/openspec/changes/online-session-management/.openspec.yaml b/openspec/changes/online-session-management/.openspec.yaml new file mode 100644 index 0000000..a369219 --- /dev/null +++ b/openspec/changes/online-session-management/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/online-session-management/design.md b/openspec/changes/online-session-management/design.md new file mode 100644 index 0000000..50a0ad5 --- /dev/null +++ b/openspec/changes/online-session-management/design.md @@ -0,0 +1,63 @@ +## 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 + +无。 diff --git a/openspec/changes/online-session-management/proposal.md b/openspec/changes/online-session-management/proposal.md new file mode 100644 index 0000000..397e7f6 --- /dev/null +++ b/openspec/changes/online-session-management/proposal.md @@ -0,0 +1,32 @@ +## Why + +当前会话不可在线观测,唯一「踢下线」手段是重置密码(连带吊销全部会话);账号安全锁定仅有登录失败触发的临时 Redis 阈值,没有管理员可见、可操作的锁定/解锁闭环。安全审计要求会话可管理、锁定可处置,租户管理员也需要一个「在线会话」面板即时强退风险账号/设备。 + +## What Changes + +- **会话标识**:JWT(access 与 refresh)引入会话标识 `sid`,使「单个会话精确强退」成为可能;「踢全部」复用现有 tokenVersion 机制。 +- **在线会话域**(新增):列出活跃会话(用户 / 设备 IP / User-Agent / 登录时间 / 过期时间),支持按单个会话强退、按用户踢全部。 +- **账号锁定**(独立字段):User 新增 `locked_at` / `lock_reason`,与 `status`(启用/停用)解耦;锁定账号在登录 / 刷新 / 鉴权处统一拒绝。 +- **用户域锁定/解锁**:管理员可锁定(附原因,锁定即踢全部会话)与解锁(清空锁定并清除登录失败计数)。 +- **前端**:新增「在线会话」页面(系统监控下);用户管理页新增「锁定/解锁」操作。 + +## Capabilities + +### New Capabilities + +- `backend/session`: 在线会话——活跃会话列表、单会话强退、按用户踢全部。 +- `frontend/AD/session`: 在线会话管理页面——会话列表 + 强退按钮 + 用户维度踢全部。 + +### Modified Capabilities + +- `backend/users`: 用户域新增「锁定/解锁」与「踢全部会话」行为。 +- `frontend/AD/user`: 用户管理页新增「锁定/解锁」操作。 +- `shared/data-model`: `user` 实体新增 `locked_at` / `lock_reason` 字段;用户状态机改为「启用/停用」+ 独立「锁定」标志。 +- `shared/api-contract`: 核心域接口目录新增 `/sessions`;用户域新增锁定/解锁/踢全部会话接口。 + +## Impact + +- **后端**:`auth`(`issue` 加 `sid`、JwtAuthGuard 校验 sid 存活、登录拒绝锁定)、`users`(lock/unlock/kick-all)、新增 `sessions` 模块;Prisma(`User` 加列);`tenant-template`(新增会话菜单/接口资源 + 用户锁定/解锁/踢全部接口资源 + 按钮)。 +- **前端**:新增 `session` 页面、`user` 页锁定/解锁操作、`api/session.ts` 与 `api/user.ts` 扩展。 +- **数据**:Prisma migration(`sys_user` 加 `locked_at` / `lock_reason`)。 +- **Redis**:新增会话吊销黑名单 key(`sessionRevoked:{sid}`,TTL = access token 剩余时长)。 diff --git a/openspec/changes/online-session-management/specs/backend/session/spec.md b/openspec/changes/online-session-management/specs/backend/session/spec.md new file mode 100644 index 0000000..a200dd0 --- /dev/null +++ b/openspec/changes/online-session-management/specs/backend/session/spec.md @@ -0,0 +1,30 @@ +## Purpose + +定义在线会话域的行为:列出活跃会话、按单个会话强退、按用户踢全部会话。 + +## ADDED Requirements + +### Requirement: 活跃会话列表 +系统 SHALL 支持按用户名/昵称/IP 分页查询当前租户的活跃会话,返回用户名、昵称、设备 IP、User-Agent、登录时间、过期时间与会话标识;列表按数据权限过滤(仅可见本人权限范围内用户的会话,超管可见全部)。 + +#### Scenario: 分页查询活跃会话 +- **WHEN** 管理员请求会话列表并传 page/pageSize 与筛选条件 +- **THEN** 系统返回满足条件的活跃会话分页结果(仅含未过期、未吊销的 refresh token 对应会话) + +#### Scenario: 数据权限过滤 +- **WHEN** 数据权限为「本部门」的管理员查询会话列表 +- **THEN** 仅返回本部门用户的活跃会话 + +### Requirement: 单会话强退 +系统 SHALL 支持指定会话标识强退单个会话:吊销该会话的 refresh token,并使其 access token 在下次请求时被拒绝(401)。 + +#### Scenario: 强退单个会话 +- **WHEN** 管理员对某会话标识执行强退 +- **THEN** 该会话的 refresh token 被吊销,其 access token 在下次请求时返回 401 + +### Requirement: 踢全部会话 +系统 SHALL 支持按用户踢出其全部活跃会话:吊销该用户所有 refresh token,并使其全部 access token 失效。 + +#### Scenario: 踢出用户全部会话 +- **WHEN** 管理员对某用户执行踢全部会话 +- **THEN** 该用户所有活跃会话的 refresh token 被吊销,全部 access token 失效 diff --git a/openspec/changes/online-session-management/specs/backend/users/spec.md b/openspec/changes/online-session-management/specs/backend/users/spec.md new file mode 100644 index 0000000..fc17d3c --- /dev/null +++ b/openspec/changes/online-session-management/specs/backend/users/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: 账号锁定 +系统 SHALL 支持管理员锁定用户(可附原因);锁定后该用户无法登录,且其全部活跃会话被吊销。 + +#### Scenario: 锁定用户 +- **WHEN** 管理员锁定某用户 +- **THEN** 系统记录锁定时间与原因,吊销该用户全部会话,该用户后续登录被拒绝 + +### Requirement: 账号解锁 +系统 SHALL 支持管理员解锁用户;解锁后清空锁定时间与原因,并清除该用户的登录失败计数。 + +#### Scenario: 解锁用户 +- **WHEN** 管理员解锁某锁定用户 +- **THEN** 系统清空锁定状态与登录失败计数,该用户恢复登录能力(若 status 为启用) diff --git a/openspec/changes/online-session-management/specs/frontend/AD/session/spec.md b/openspec/changes/online-session-management/specs/frontend/AD/session/spec.md new file mode 100644 index 0000000..52c18a9 --- /dev/null +++ b/openspec/changes/online-session-management/specs/frontend/AD/session/spec.md @@ -0,0 +1,26 @@ +## Purpose + +定义在线会话管理页面的行为:分页展示活跃会话、单个会话强退、按用户踢全部。 + +## ADDED Requirements + +### Requirement: 会话列表展示 +页面 SHALL 分页展示活跃会话(用户名/昵称/设备 IP/User-Agent/登录时间/过期时间),支持按用户名与 IP 筛选。 + +#### Scenario: 展示会话列表 +- **WHEN** 用户进入在线会话页面 +- **THEN** 页面展示当前租户活跃会话的分页列表 + +### Requirement: 单个会话强退 +页面 SHALL 支持对单个会话执行强退操作,强退前需确认,成功后刷新列表。 + +#### Scenario: 强退单个会话 +- **WHEN** 用户对某会话点击强退并确认 +- **THEN** 该会话被强退,列表刷新且该会话消失 + +### Requirement: 踢全部会话 +页面 SHALL 支持对某用户执行「踢全部会话」操作,强退前需确认。 + +#### Scenario: 踢出用户全部会话 +- **WHEN** 用户对某用户执行踢全部并确认 +- **THEN** 该用户所有活跃会话被强退,列表刷新 diff --git a/openspec/changes/online-session-management/specs/frontend/AD/user/spec.md b/openspec/changes/online-session-management/specs/frontend/AD/user/spec.md new file mode 100644 index 0000000..1906d88 --- /dev/null +++ b/openspec/changes/online-session-management/specs/frontend/AD/user/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: 锁定/解锁操作 +用户管理页 SHALL 支持对用户执行锁定(可填原因)与解锁操作;锁定/解锁后刷新列表并提示结果。 + +#### Scenario: 锁定用户 +- **WHEN** 管理员对某启用用户执行锁定并(可选)填写原因 +- **THEN** 系统锁定该用户并刷新列表,状态显示为锁定 + +#### Scenario: 解锁用户 +- **WHEN** 管理员对某锁定用户执行解锁 +- **THEN** 系统解锁该用户并刷新列表 diff --git a/openspec/changes/online-session-management/specs/shared/api-contract/spec.md b/openspec/changes/online-session-management/specs/shared/api-contract/spec.md new file mode 100644 index 0000000..8658594 --- /dev/null +++ b/openspec/changes/online-session-management/specs/shared/api-contract/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: 核心域接口目录 +系统 SHALL 为每个核心域提供 REST 资源接口(复数、kebab-case):/users、/roles、/resources、/orgs、/dict-types、/configs、/audit-logs、/login-logs、/sessions。 + +#### Scenario: 资源 CRUD +- **WHEN** 管理端操作某核心域 +- **THEN** 通过对应复数资源路径进行增删改查 + +## ADDED Requirements + +### Requirement: 会话与账号安全接口 +系统 SHALL 提供以下接口:`GET /sessions`(分页查询活跃会话)、`POST /sessions/:sid/kick`(单会话强退)、`POST /users/:id/kick-sessions`(踢用户全部会话)、`POST /users/:id/lock`(锁定用户,body 可含 reason)、`POST /users/:id/unlock`(解锁用户)。 + +#### Scenario: 查询活跃会话 +- **WHEN** 客户端请求 GET /sessions +- **THEN** 返回活跃会话分页数据(list/total/page/pageSize) + +#### Scenario: 单会话强退 +- **WHEN** 客户端请求 POST /sessions/:sid/kick +- **THEN** 该会话被强退 + +#### Scenario: 锁定与解锁 +- **WHEN** 客户端请求 POST /users/:id/lock 或 /users/:id/unlock +- **THEN** 用户被锁定或解锁 diff --git a/openspec/changes/online-session-management/specs/shared/data-model/spec.md b/openspec/changes/online-session-management/specs/shared/data-model/spec.md new file mode 100644 index 0000000..8aa19fa --- /dev/null +++ b/openspec/changes/online-session-management/specs/shared/data-model/spec.md @@ -0,0 +1,12 @@ +## MODIFIED Requirements + +### Requirement: 用户状态机 +user 实体 SHALL 通过 `status` 字段支持启用/停用(管理员手动启停),并通过独立的 `locked_at`(锁定时间,可空)与 `lock_reason`(锁定原因,可空)表示安全锁定;「锁定」由 `locked_at` 非空推导,`status` 仅存 enabled/disabled。停用或锁定的用户 SHALL 均无法登录。 + +#### Scenario: 锁定登录 +- **WHEN** 锁定用户(locked_at 非空)尝试登录 +- **THEN** 系统拒绝登录并提示账号已锁定 + +#### Scenario: 停用登录 +- **WHEN** 停用用户(status=disabled)尝试登录 +- **THEN** 系统拒绝登录并提示账号已停用 diff --git a/openspec/changes/online-session-management/tasks.md b/openspec/changes/online-session-management/tasks.md new file mode 100644 index 0000000..50d771b --- /dev/null +++ b/openspec/changes/online-session-management/tasks.md @@ -0,0 +1,46 @@ +## 1. 数据模型与迁移 + +- [x] 1.1 Prisma:`User` 新增 `lockedAt`/`lockReason`(可空,snake_case 映射 `locked_at`/`lock_reason`) +- [x] 1.2 Prisma:`RefreshToken` 新增 `sid`(uuid v7,可空,加索引) +- [x] 1.3 生成并部署 migration(`prisma migrate` / `migrate diff` + `deploy`) + +## 2. 认证核心(sid + 锁定判定) + +- [x] 2.1 `issue()` 增加 `sid` 参数并写入 access token 载荷;登录时生成新 sid,刷新时沿用旧 sid 并携带 `loginAt/ip/userAgent`、延长 `expireAt` +- [x] 2.2 `loginTenant` 与 `refresh` 拒绝 `lockedAt != null` 的账号 +- [x] 2.3 `checkUserActive` 的 user-state 缓存对象扩展为 `{status, tokenVersion, lockedAt}`,`!lockedAt` 才放行 +- [x] 2.4 `JwtAuthGuard` 在 checkUserActive 后追加 sid 黑名单判定(Redis `sessionRevoked:{sid}`,命中即 401) + +## 3. 在线会话模块 + +- [x] 3.1 新增 `sessions` 模块(service + controller + DTO) +- [x] 3.2 `GET /sessions`:分页查询活跃会话(join user 取 username/nickname),按用户名/IP 筛选,按数据权限过滤可见用户 +- [x] 3.3 `POST /sessions/:sid/kick`:吊销该 sid 的 refresh token + 写 Redis 黑名单(TTL=ACCESS_TTL) + +## 4. 用户锁定/解锁与踢全部 + +- [x] 4.1 `users` service 新增 `lock`/`unlock`/`kickSessions` +- [x] 4.2 `lock`:置 `lockedAt/lockReason` + 调 `invalidateSessions`;`unlock`:清锁定 + 清登录失败计数(loginFail key) +- [x] 4.3 `users` controller 新增 `POST /users/:id/lock`、`POST /users/:id/unlock`、`POST /users/:id/kick-sessions` + +## 5. 资源注册与存量补齐 + +- [x] 5.1 `Permissions` 枚举新增 `session:list`、`session:kick`、`user:kickSessions`、`user:lock`、`user:unlock` +- [x] 5.2 `tenant-template` 新增 session 菜单(parent=monitor,/monitor/session)与上述接口资源 +- [x] 5.3 提取 `syncTenantResources`(仅资源+字典+授权,不触碰用户/密码),提供存量租户补齐脚本并执行 + +## 6. 前端 API + +- [x] 6.1 新增 `api/session.ts`:`listSessions`、`kickSession` +- [x] 6.2 `api/user.ts` 扩展 `kickSessions`、`lockUser`、`unlockUser` + +## 7. 前端页面 + +- [x] 7.1 新增 `pages/monitor/session/index.vue`:会话列表 + 单个强退 + 按用户踢全部(确认框) +- [x] 7.2 `pages/system/user/index.vue`:新增「锁定/解锁」操作,锁定状态以标签展示 + +## 8. 验证 + +- [x] 8.1 后端 build + 单测通过 +- [x] 8.2 前端 type-check 通过 +- [x] 8.3 冒烟测试:登录 → 锁定用户(旧 token 失效)→ 会话列表 → 单会话强退 → 解锁后恢复登录 diff --git a/sources/sysmgr-admin b/sources/sysmgr-admin index d37af1b..ce45d93 160000 --- a/sources/sysmgr-admin +++ b/sources/sysmgr-admin @@ -1 +1 @@ -Subproject commit d37af1b73e82fd9f4f0350f011ca3e1ab580b2c4 +Subproject commit ce45d93ce5526835efb1410caa7fa29fae43edac diff --git a/sources/sysmgr-nest b/sources/sysmgr-nest index 127707f..86832ce 160000 --- a/sources/sysmgr-nest +++ b/sources/sysmgr-nest @@ -1 +1 @@ -Subproject commit 127707ffde442b907b9db4aabca6f4722e7f75fc +Subproject commit 86832ce32d55310728e00d7e66ca034233e688ec