From 5685a5ce42370c029b23f62cbec81f9c9c42221e Mon Sep 17 00:00:00 2001 From: liuyun Date: Sun, 11 Oct 2026 21:54:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E9=A6=96=E7=99=BB=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=E6=94=B9=E5=AF=86=EF=BC=88=E5=AD=90=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=20+=20openspec=20change=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../force-password-change/.openspec.yaml | 2 + .../changes/force-password-change/design.md | 57 +++++++++++++++++++ .../changes/force-password-change/proposal.md | 29 ++++++++++ .../specs/backend/auth/spec.md | 26 +++++++++ .../frontend/AD/force-password-change/spec.md | 19 +++++++ .../specs/frontend/AD/user/spec.md | 12 ++++ .../specs/shared/api-contract/spec.md | 12 ++++ .../specs/shared/data-model/spec.md | 16 ++++++ .../changes/force-password-change/tasks.md | 31 ++++++++++ sources/sysmgr-admin | 2 +- sources/sysmgr-nest | 2 +- 11 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 openspec/changes/force-password-change/.openspec.yaml create mode 100644 openspec/changes/force-password-change/design.md create mode 100644 openspec/changes/force-password-change/proposal.md create mode 100644 openspec/changes/force-password-change/specs/backend/auth/spec.md create mode 100644 openspec/changes/force-password-change/specs/frontend/AD/force-password-change/spec.md create mode 100644 openspec/changes/force-password-change/specs/frontend/AD/user/spec.md create mode 100644 openspec/changes/force-password-change/specs/shared/api-contract/spec.md create mode 100644 openspec/changes/force-password-change/specs/shared/data-model/spec.md create mode 100644 openspec/changes/force-password-change/tasks.md diff --git a/openspec/changes/force-password-change/.openspec.yaml b/openspec/changes/force-password-change/.openspec.yaml new file mode 100644 index 0000000..a369219 --- /dev/null +++ b/openspec/changes/force-password-change/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/force-password-change/design.md b/openspec/changes/force-password-change/design.md new file mode 100644 index 0000000..cbcd8fa --- /dev/null +++ b/openspec/changes/force-password-change/design.md @@ -0,0 +1,57 @@ +## Context + +用户已有 `status`(启停)、`lockedAt/lockReason`(锁定)与 `tokenVersion`;登录在 `loginTenant` 内校验,`checkUserActive` 每请求经 Redis user-state 缓存校验。`PATCH /auth/me/password`(`changePassword`)已支持改本人密码(校验原密码、tokenVersion+1、删 refresh)。动机见 proposal.md - Why。 + +## Goals / Non-Goals + +**Goals:** + +- 临时密码(管理员创建/重置、建租户随机密码)强制首登改密,且后端硬门禁不可绕过。 + +**Non-Goals:** + +- 密码复杂度/历史/过期策略(另立 change)。 +- 手机/邮箱找回密码流程。 + +## Decisions + +### D1 — `must_change_password` 布尔列 +`User` 新增 `must_change_password`(Boolean,非空,默认 false)。现有用户无需回填(默认 false,即不强制)。 + +### D2 — 硬门禁内置于 JwtAuthGuard +user-state 缓存对象扩展为 `{ status, tokenVersion, lockedAt, mustChangePassword }`;`checkUserActive` 改为返回 `{ active, mustChangePassword }`(单次 Redis 读)。守卫在 `active` 通过后,若 `mustChangePassword` 且请求非白名单 → 403「请先修改初始密码」。 +- **白名单仅 `PATCH /auth/me/password`**(改本人密码)。`login/refresh/logout` 为 `@Public` 不经过守卫天然放行;登出依赖 refresh token、无需门禁。 +- **备选**:独立 `MustChangePasswordGuard`(新 APP_GUARD)——否决,多一个全局守卫且需与 JwtAuthGuard 共享 user-state,不如内聚到一处。 + +### D3 — 触发置标记 +置 `mustChangePassword=true`: +1. `users.createUser`:`CreateUserDto` 新增可选 `mustChangePassword`(默认 true,管理员可显式关闭); +2. `users.resetPassword`(管理员重置,新临时密码,恒强制); +3. `tenants.createTenant`(随机密码),经 `initTenant` 新增 `forcePasswordChange` 选项传入(恒强制)。 + +不置(`false`): +- `auth.register`(用户自选密码); +- `seed` 的平台自举管理员(`touyun/Touyun@123`,开发与 e2e/smoke 依赖其可直接登录)。 + +### D4 — 登录返回标志、改密清除 +`loginTenant` 返回 `mustChangePassword`(直接读已取到的 user 行),自动探测登录路径同步透传;`changePassword` 成功时 `must_change_password: false`。 + +### D5 — 前端强制流程 + 状态持久化 +`auth` store 新增 `mustChangePassword`,登录后写入并持久化(sessionStorage,抗刷新);路由守卫:待改密且目标非改密页/登出时重定向改密页。改密页为**独立页**(不进布局,不依赖 menus/permissions),提交成功后 `reset()` 清会话并跳登录。 +- **为什么独立页**:硬门禁会 403 menus/permissions,若改密页在布局内会被门禁卡住。 + +## Risks / Trade-offs + +- **[门禁误伤]** → 白名单最小化到仅 `PATCH /auth/me/password`;`@Public` 接口天然绕过不受影响。 +- **[seed 管理员不强制]** → 仅开发自举账号豁免;生产建租户/建用户/重置密码均强制,安全闭环不缺失。 +- **[前端刷新丢状态]** → 后端硬门禁兜底(即便前端标志丢失,其余接口仍 403);前端仍持久化标志以提供干净重定向体验。 + +## Migration Plan + +1. Prisma migration:`sys_user` 加 `must_change_password BOOLEAN NOT NULL DEFAULT false`(无回填)。 +2. 代码上线(守卫门禁 + 登录标志 + 触发点)。 +3. 回滚:向后兼容,回滚代码即可。 + +## Open Questions + +无。 diff --git a/openspec/changes/force-password-change/proposal.md b/openspec/changes/force-password-change/proposal.md new file mode 100644 index 0000000..fe54fc5 --- /dev/null +++ b/openspec/changes/force-password-change/proposal.md @@ -0,0 +1,29 @@ +## Why + +管理员创建用户、重置密码、建租户下发的都是临时/初始密码,但系统没有「首登强制改密」机制——临时密码可被长期使用,一旦泄漏即越权。企业后台需要一条强制闭环:拿到临时密码 → 首登被拦 → 改密 → 才可用系统。 + +## What Changes + +- **待改密标记**:`user` 新增 `must_change_password`(布尔,默认 false);管理员重置密码、`createTenant` 下发随机密码时置 true,管理员创建用户时默认强制(可关闭),用户自行改密成功后清 false。 +- **登录返回标志**:登录响应新增 `mustChangePassword`。 +- **后端硬门禁**:未改密用户仅可访问「修改本人密码 / 登出」,其余接口返回 403。 +- **前端强制流程**:登录后若待改密,强制跳转改密页并拦截其它路由;改密成功后清会话、跳登录重新认证。 + +## Capabilities + +### New Capabilities + +- `frontend/AD/force-password-change`: 首登强制改密页面与路由守卫(强制跳转、改密后重新登录)。 + +### Modified Capabilities + +- `shared/data-model`: `user` 实体新增 `must_change_password` 待改密标记。 +- `shared/api-contract`: 登录响应新增 `mustChangePassword` 标志。 +- `backend/auth`: 登录返回待改密标志、改密清除标志、新增「待改密门禁」。 +- `frontend/AD/user`: 创建用户表单新增「首登强制改密」配置项。 + +## Impact + +- **后端**:`auth`(login 返回标志、changePassword 清标志、JwtAuthGuard 加门禁)、`users`(createUser 接受 mustChangePassword 参数、resetPassword 置标记)、`tenants`(createTenant 置标记)、`tenant-template`(initTenant 加 forcePasswordChange 选项);Prisma migration(`sys_user` 加列)。 +- **前端**:登录 store 保存待改密状态、路由守卫拦截、新增强制改密页、用户创建表单加「首登强制改密」开关、`api/auth.ts` 类型扩展。 +- **数据**:Prisma migration(`sys_user` 加 `must_change_password`)。 diff --git a/openspec/changes/force-password-change/specs/backend/auth/spec.md b/openspec/changes/force-password-change/specs/backend/auth/spec.md new file mode 100644 index 0000000..ef90dd0 --- /dev/null +++ b/openspec/changes/force-password-change/specs/backend/auth/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: 待改密门禁 +系统 SHALL 对 `must_change_password=true` 的用户实施门禁:除「修改本人密码」与「登出」接口外,其余接口返回 403(提示需先修改密码)。 + +#### Scenario: 待改密访问受保护接口 +- **WHEN** 待改密用户请求除改密/登出外的任意接口 +- **THEN** 系统返回 403 并提示需先修改密码 + +#### Scenario: 待改密改密与登出放行 +- **WHEN** 待改密用户请求修改密码或登出接口 +- **THEN** 系统放行 + +### Requirement: 登录返回待改密标志 +登录成功 SHALL 在响应中返回 mustChangePassword,值为该用户是否待改密。 + +#### Scenario: 待改密登录 +- **WHEN** 待改密用户登录成功 +- **THEN** 响应含 mustChangePassword=true + +### Requirement: 改密清除待改密 +用户修改本人密码成功 SHALL 清除其待改密标记。 + +#### Scenario: 改密后清除 +- **WHEN** 待改密用户修改密码成功 +- **THEN** 其 must_change_password 置为 false diff --git a/openspec/changes/force-password-change/specs/frontend/AD/force-password-change/spec.md b/openspec/changes/force-password-change/specs/frontend/AD/force-password-change/spec.md new file mode 100644 index 0000000..d718010 --- /dev/null +++ b/openspec/changes/force-password-change/specs/frontend/AD/force-password-change/spec.md @@ -0,0 +1,19 @@ +## Purpose + +定义首登强制改密页面的行为:登录后对待改密用户强制跳转改密页,改密成功后重新登录。 + +## ADDED Requirements + +### Requirement: 强制跳转改密页 +登录后若 mustChangePassword=true,前端 SHALL 强制跳转改密页并禁止进入其它页面(路由守卫拦截)。 + +#### Scenario: 待改密强制跳转 +- **WHEN** 用户登录成功且 mustChangePassword=true +- **THEN** 前端跳转到改密页,访问其它路由被重定向回改密页 + +### Requirement: 改密后重新登录 +改密页 SHALL 提供修改密码表单(新密码);提交成功后清空会话并跳转登录页重新登录。 + +#### Scenario: 改密成功 +- **WHEN** 用户在改密页提交新密码成功 +- **THEN** 前端清空会话并跳转登录页,用户用新密码重新登录 diff --git a/openspec/changes/force-password-change/specs/frontend/AD/user/spec.md b/openspec/changes/force-password-change/specs/frontend/AD/user/spec.md new file mode 100644 index 0000000..ae9f8a1 --- /dev/null +++ b/openspec/changes/force-password-change/specs/frontend/AD/user/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: 创建用户指定强制改密 +新增用户表单 SHALL 提供「首登强制改密」配置项(默认勾选);提交时将其作为 mustChangePassword 传入。 + +#### Scenario: 默认强制改密 +- **WHEN** 管理员新增用户保持「首登强制改密」勾选并提交 +- **THEN** 创建的该用户 mustChangePassword=true + +#### Scenario: 关闭强制改密 +- **WHEN** 管理员新增用户取消勾选「首登强制改密」 +- **THEN** 创建的该用户 mustChangePassword=false diff --git a/openspec/changes/force-password-change/specs/shared/api-contract/spec.md b/openspec/changes/force-password-change/specs/shared/api-contract/spec.md new file mode 100644 index 0000000..8fa956b --- /dev/null +++ b/openspec/changes/force-password-change/specs/shared/api-contract/spec.md @@ -0,0 +1,12 @@ +## MODIFIED Requirements + +### Requirement: 认证接口 +系统 SHALL 提供登录、刷新令牌、登出接口;登录需 tenantCode+username+password,返回 access token、refresh token 与 mustChangePassword(待改密标志)。 + +#### Scenario: 登录成功 +- **WHEN** 用户提供正确的租户、账号与密码 +- **THEN** 返回 access token(2h 过期)、refresh token(7d 过期)与 mustChangePassword(待改密为 true,否则 false) + +#### Scenario: 刷新令牌 +- **WHEN** access 过期后客户端携带 refresh token 请求刷新 +- **THEN** 系统签发新 access 与 refresh,并作废旧 refresh diff --git a/openspec/changes/force-password-change/specs/shared/data-model/spec.md b/openspec/changes/force-password-change/specs/shared/data-model/spec.md new file mode 100644 index 0000000..909f931 --- /dev/null +++ b/openspec/changes/force-password-change/specs/shared/data-model/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: 待改密标记 +user 实体 SHALL 通过 `must_change_password`(布尔,默认 false)标记待改密状态:管理员重置密码、租户初始化下发随机密码、创建用户(默认)时置 true;创建用户时管理员可显式关闭;用户自行改密成功后置 false。 + +#### Scenario: 临时密码置标记 +- **WHEN** 管理员重置密码或初始化租户下发临时密码 +- **THEN** 该用户 must_change_password 为 true + +#### Scenario: 创建用户默认强制改密 +- **WHEN** 管理员创建用户(未显式关闭「首登强制改密」) +- **THEN** 该用户 must_change_password 为 true;显式关闭则为 false + +#### Scenario: 改密清除标记 +- **WHEN** 用户修改本人密码成功 +- **THEN** 该用户 must_change_password 置为 false diff --git a/openspec/changes/force-password-change/tasks.md b/openspec/changes/force-password-change/tasks.md new file mode 100644 index 0000000..8f10b57 --- /dev/null +++ b/openspec/changes/force-password-change/tasks.md @@ -0,0 +1,31 @@ +## 1. 数据模型与迁移 + +- [x] 1.1 Prisma:`User` 新增 `mustChangePassword`(Boolean,非空,默认 false,snake_case 映射 `must_change_password`) +- [x] 1.2 生成并部署 migration(`migrate dev --create-only` + `migrate deploy`) + +## 2. 认证核心(标志 + 门禁) + +- [x] 2.1 `checkUserActive` 改为返回 `{ active, mustChangePassword }`,user-state 缓存对象加 `mustChangePassword` +- [x] 2.2 `JwtAuthGuard` 加待改密门禁:待改密且非 `PATCH /auth/me/password` → 403「请先修改初始密码」 +- [x] 2.3 `loginTenant` 与自动探测登录响应返回 `mustChangePassword` +- [x] 2.4 `changePassword` 成功时清 `must_change_password` + +## 3. 触发置标记 + +- [x] 3.1 `CreateUserDto` 新增可选 `mustChangePassword`(默认 true),`users.createUser` 按入参落库 +- [x] 3.2 `users.resetPassword` 置 `mustChangePassword=true` +- [x] 3.3 `initTenant` 新增 `forcePasswordChange` 选项;`createTenant` 传 true、`seed` 传 false + +## 4. 前端强制流程 + +- [x] 4.1 `auth` store 新增 `mustChangePassword` 状态并持久化(sessionStorage) +- [x] 4.2 `api/auth.ts` 的 `LoginResult` 类型扩展 `mustChangePassword` +- [x] 4.3 路由守卫拦截:待改密且目标非改密页/登出时重定向改密页 +- [x] 4.4 新增独立改密页(不进布局),改密成功后 `reset()` + 跳登录页 +- [x] 4.5 用户创建表单新增「首登强制改密」开关(默认勾选),`api/user.ts` 的 `UserUpsertData` 扩展 `mustChangePassword` 并在提交时传参 + +## 5. 验证 + +- [x] 5.1 后端 build + 单测 + e2e 通过 +- [x] 5.2 前端 type-check 通过 +- [x] 5.3 冒烟测试:创建用户(待改密)→ 登录返回 mustChangePassword=true → 其它接口 403 → 改密成功 → 重新登录正常 diff --git a/sources/sysmgr-admin b/sources/sysmgr-admin index ce45d93..5de298a 160000 --- a/sources/sysmgr-admin +++ b/sources/sysmgr-admin @@ -1 +1 @@ -Subproject commit ce45d93ce5526835efb1410caa7fa29fae43edac +Subproject commit 5de298a9569027f4b40766814733bea209ccada4 diff --git a/sources/sysmgr-nest b/sources/sysmgr-nest index 86832ce..9869974 160000 --- a/sources/sysmgr-nest +++ b/sources/sysmgr-nest @@ -1 +1 @@ -Subproject commit 86832ce32d55310728e00d7e66ca034233e688ec +Subproject commit 9869974d4c53a7e1062c80b35d90c7a29d8c632a