feat(session): 在线会话强踢 + 账号锁定/解锁(子模块 + openspec change)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
695df1a395
commit
a8e8ef8606
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-11
|
||||
63
openspec/changes/online-session-management/design.md
Normal file
63
openspec/changes/online-session-management/design.md
Normal file
@ -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
|
||||
|
||||
无。
|
||||
32
openspec/changes/online-session-management/proposal.md
Normal file
32
openspec/changes/online-session-management/proposal.md
Normal file
@ -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 剩余时长)。
|
||||
@ -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 失效
|
||||
@ -0,0 +1,15 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 账号锁定
|
||||
系统 SHALL 支持管理员锁定用户(可附原因);锁定后该用户无法登录,且其全部活跃会话被吊销。
|
||||
|
||||
#### Scenario: 锁定用户
|
||||
- **WHEN** 管理员锁定某用户
|
||||
- **THEN** 系统记录锁定时间与原因,吊销该用户全部会话,该用户后续登录被拒绝
|
||||
|
||||
### Requirement: 账号解锁
|
||||
系统 SHALL 支持管理员解锁用户;解锁后清空锁定时间与原因,并清除该用户的登录失败计数。
|
||||
|
||||
#### Scenario: 解锁用户
|
||||
- **WHEN** 管理员解锁某锁定用户
|
||||
- **THEN** 系统清空锁定状态与登录失败计数,该用户恢复登录能力(若 status 为启用)
|
||||
@ -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** 该用户所有活跃会话被强退,列表刷新
|
||||
@ -0,0 +1,12 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 锁定/解锁操作
|
||||
用户管理页 SHALL 支持对用户执行锁定(可填原因)与解锁操作;锁定/解锁后刷新列表并提示结果。
|
||||
|
||||
#### Scenario: 锁定用户
|
||||
- **WHEN** 管理员对某启用用户执行锁定并(可选)填写原因
|
||||
- **THEN** 系统锁定该用户并刷新列表,状态显示为锁定
|
||||
|
||||
#### Scenario: 解锁用户
|
||||
- **WHEN** 管理员对某锁定用户执行解锁
|
||||
- **THEN** 系统解锁该用户并刷新列表
|
||||
@ -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** 用户被锁定或解锁
|
||||
@ -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** 系统拒绝登录并提示账号已停用
|
||||
46
openspec/changes/online-session-management/tasks.md
Normal file
46
openspec/changes/online-session-management/tasks.md
Normal file
@ -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 失效)→ 会话列表 → 单会话强退 → 解锁后恢复登录
|
||||
@ -1 +1 @@
|
||||
Subproject commit d37af1b73e82fd9f4f0350f011ca3e1ab580b2c4
|
||||
Subproject commit ce45d93ce5526835efb1410caa7fa29fae43edac
|
||||
@ -1 +1 @@
|
||||
Subproject commit 127707ffde442b907b9db4aabca6f4722e7f75fc
|
||||
Subproject commit 86832ce32d55310728e00d7e66ca034233e688ec
|
||||
Loading…
x
Reference in New Issue
Block a user