From 39f8c1d134d3525c8fb9270b65fc2d72f729a900 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 24 Sep 2026 04:18:58 +0000 Subject: [PATCH] feat(auth): admin password reset API and credential guards Implement T20-F4-B reset-password endpoint with one-time temp password, audit table, frontend wiring, login error envelope parsing, and CreateUser fail-closed for empty password hashes. Recovered test chenwei via env write (password out of band). --- .../2026-09-24-admin-password-reset-issues.md | 67 +++++++++++ .doc/audit/ISSUES.md | 7 +- .doc/ops/CURRENT.md | 1 + .../2026-09-24-admin-password-reset.md | 37 ++++++ ...-09-24-admin-password-reset-test-report.md | 74 ++++++++++++ .../cred-inventory.txt | 19 +++ .../go-focused.txt | 2 + .../phase0-login-verify.txt | 8 ++ ...7_2026-09-24-user-password-reset-audit.sql | 20 ++++ internal/auth/admin_reset_password_test.go | 103 ++++++++++++++++ internal/auth/crypto.go | 24 ++++ internal/auth/pg_store.go | 25 ++-- internal/auth/service.go | 48 ++++++++ internal/auth/store.go | 10 ++ internal/server/auth_handlers.go | 36 ++++++ internal/server/auth_handlers_test.go | 112 ++++++++++++++++++ internal/server/server.go | 1 + web/src/api/auth.test.ts | 10 ++ web/src/api/auth.ts | 55 ++++++++- .../admin/console/sections/UsersSection.tsx | 34 ++++-- 20 files changed, 668 insertions(+), 25 deletions(-) create mode 100644 .doc/audit/2026-09-24-admin-password-reset-issues.md create mode 100644 .doc/ops/short-term/2026-09-24-admin-password-reset.md create mode 100644 .doc/test-reports/2026-09-24-admin-password-reset-test-report.md create mode 100644 .doc/test-reports/assets/2026-09-24-admin-password-reset/cred-inventory.txt create mode 100644 .doc/test-reports/assets/2026-09-24-admin-password-reset/go-focused.txt create mode 100644 .doc/test-reports/assets/2026-09-24-admin-password-reset/phase0-login-verify.txt create mode 100644 deploy/postgres/goose/00067_2026-09-24-user-password-reset-audit.sql create mode 100644 internal/auth/admin_reset_password_test.go diff --git a/.doc/audit/2026-09-24-admin-password-reset-issues.md b/.doc/audit/2026-09-24-admin-password-reset-issues.md new file mode 100644 index 00000000..e3219599 --- /dev/null +++ b/.doc/audit/2026-09-24-admin-password-reset-issues.md @@ -0,0 +1,67 @@ +# 管理员重置密码 / 登录凭据缺陷 — 问题主记录 + +更新:2026-09-24。任务:`admin-password-reset-2026-09-24`。 + +## ISSUE-20260924-admin-reset-dead + +### 问题 + +管理台「重置密码」按钮调用 `authClient.adminResetPassword`,客户端硬 `throw`,后端无 `reset-password` 路由;UI 仍硬编码 `ResetPass123` 暗示成功。验收脚本曾重置 `chenwei` 哈希且不披露口令后,管理员无法通过 UI 恢复访问。 + +### 根因 + +T20-F4 已设计未实施(选项 B 卡产品)。`password_changed_at` 停在 2026-09-20 证明重置从未入库。 + +### 修复 + +`POST /api/v1/admin/users/{id}/reset-password`:服务端随机临时口令、`must_change_password=true`、撤销会话、审计表 `user_password_reset_audits`(goose 00067);前端接线并去掉硬编码。 + +### 状态 + +resolved(本任务合入后)。 + +--- + +## ISSUE-20260924-login-error-envelope + +### 问题 + +登录失败响应 `code=AUTHZ` / `message=无权访问`,前端再把整段 JSON 原文塞进 `Error.message`,用户误判为权限问题。 + +### 根因 + +`httpx.CodeFromStatus(401)=AUTHZ` + `SafeMessage`;`auth.ts` login 未用信封解析、未优先 `error` 领域字段。 + +### 修复 + +`authErrorMessage` 优先取信封 `error`;不改全局 AUTHZ 映射(事件复用与安全取舍另立专项)。 + +### 状态 + +resolved。 + +--- + +## ISSUE-20260924-zombie-credentials + +### 问题 + +`CreateUser` 在 `PasswordHash==""` 时静默跳过 `user_credentials` 插入;`LEFT JOIN` + `COALESCE` 使登录哈希为空串,任意密码均失败且报错与真凭据错误相同。 + +### 盘点(只读,2026-09-24) + +| 环境 | total | missing_cred | empty_username | +|---|---|---|---| +| dev | 416 | 263 | 255 | +| test | 9 | 0 | 0 | +| main | 4 | 3 | 0 | + +main 三行为 goose 00011 seed(`seed-admin` 等),非业务用户。本任务**不清理**存量;仅 fail-closed 防新增。 + +### 修复 + +PgStore/MemoryStore `CreateUser` 拒绝空哈希;`Login` 对空哈希记 `slog.Warn(user_id)` 仍统一 401。 + +### 状态 + +resolved(机制);存量清理 = open / 另授权。 diff --git a/.doc/audit/ISSUES.md b/.doc/audit/ISSUES.md index 1515a3c1..3a6fc1b8 100644 --- a/.doc/audit/ISSUES.md +++ b/.doc/audit/ISSUES.md @@ -2,11 +2,14 @@ 维护规则:[问题记录与开发经验闭环](../ops/2026-09-23-issue-learning-workflow.md)。每个问题一个稳定 ID;主记录是事实与状态来源,本表为可检索投影。新发现及时登记,修复后保留记录;发布 blocker 仍见 [gap-matrix](../remediation/gap-matrix.md)。 -更新:2026-09-23。本轮为初始建库,仅补录两个历史案例;下列历史 resolved 不表示本日运行回归通过。 +更新:2026-09-24。登记管理员重置密码死功能、登录信封误导、无凭据僵尸账号;历史 resolved 不表示本日运行回归通过。 | issue_id | 模块 / 检索词 | 问题 | 严重度 | issue_state | owner | 证据日期 / 范围 | 唯一主记录 | 经验 | |---|---|---|---|---|---|---|---|---| | ISSUE-20260923-nullable-duty | docgov / nullable / 23503 / reviewer_id | 可选职责空值写 0 导致 FK 失败 | high | resolved | Codex(历史补录) | 2026-09-17;历史 PG / API 冒烟;当前未复测 | [主记录](2026-09-23-historical-issue-records.md#nullable-duty) | [LESSON-20260923-nullable-duty](../ops/lessons/2026-09-23-nullable-duty.md) | | ISSUE-20260923-content-hash | docrevision / canonicalize / hard-break / 404 | 规范化破坏 Markdown 语义并造成正文哈希漂移 | critical | resolved | Codex(历史补录) | 2026-09-14;历史真实 UI / 重启;当前未复测 | [主记录](2026-09-23-historical-issue-records.md#content-hash) | [LESSON-20260923-content-hash](../ops/lessons/2026-09-23-content-hash.md) | | ISSUE-20260923-coordination-authority | agentctl / claim / SQLite / CAS / CURRENT / FEATURE-MATRIX | 本地副本 CAS 与任务许可不能表达跨主机全局权威认领 | high | investigating | Codex(研究) | 2026-09-23;源码/只读状态,未复现具体丢写 | [主记录](../ops/short-term/2026-09-23-coordination-db-daemon-research.md#issue-20260923-coordination-authority) | 沿用 content-hash 投影经验;跨机结论待实施验证 | -| ISSUE-20260923-matrix-status-regression | FEATURE-MATRIX / module-batches-ag / merge / docs-apply | 矩阵行 done 被 closeout 合并冲突手解回退为 planned | high | resolved | Cursor | 2026-09-23;本机 dev tip 与 git 考古;docs apply 已恢复 | [主记录](2026-09-23-matrix-status-regression.md) | [LESSON-20260923-matrix-status-cas](../ops/lessons/2026-09-23-matrix-status-cas.md) | +| ISSUE-20260923-matrix-status-regression | FEATURE-MATRIX / module-batches-ag / merge / docs-apply | 矩阵行 done 被 closeout 合并冲突手解回退为 planned | high | resolved | Cursor | 2026-09-23;本机 tip 与 git 考古;docs apply 已恢复 | [主记录](2026-09-23-matrix-status-regression.md) | [LESSON-20260923-matrix-status-cas](../ops/lessons/2026-09-23-matrix-status-cas.md) | +| ISSUE-20260924-admin-reset-dead | auth / adminResetPassword / T20-F4 / chenwei | 管理台重置密码死按钮;验收口令不可知后无法恢复 | high | resolved | Cursor | 2026-09-24;test 库实查 + API 单测 | [主记录](2026-09-24-admin-password-reset-issues.md#issue-20260924-admin-reset-dead) | 待补 LESSON | +| ISSUE-20260924-login-error-envelope | auth / AUTHZ / 无权访问 / auth.ts | 登录失败展示裸 JSON / 「无权访问」误导 | medium | resolved | Cursor | 2026-09-24;wire 复现 + 前端单测 | [主记录](2026-09-24-admin-password-reset-issues.md#issue-20260924-login-error-envelope) | 待补 LESSON | +| ISSUE-20260924-zombie-credentials | auth / user_credentials / LEFT JOIN / CreateUser | 空哈希静默建号导致永久无法登录 | high | resolved | Cursor | 2026-09-24;三环境只读盘点;机制修复不含存量清理 | [主记录](2026-09-24-admin-password-reset-issues.md#issue-20260924-zombie-credentials) | 待补 LESSON | diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 26710eae..8ffda1ed 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -18,6 +18,7 @@ | owner | task | branch | worktree | mutex | status | |---|---|---|---|---|---| +| Cursor | admin-password-reset-2026-09-24 | feature/admin-password-reset-2026-09-24 | .worktrees/admin-password-reset-2026-09-24 | auth+env:test | in_progress | | Cursor | fix-fav-staging-uuid-2026-09-24 | fix/fix-fav-staging-uuid-2026-09-24 | .worktrees/fix-fav-staging-uuid-2026-09-24 | artifactvault | done | | Cursor | gate-e1a-unique-project-name-2026-09-24 | fix/gate-e1a-unique-project-name-2026-09-24 | .worktrees/gate-e1a-unique-project-name-2026-09-24 | docnotify | in_progress | | Cursor | gate-unhook-docquery-2026-09-24 | fix/gate-unhook-docquery-2026-09-24 | .worktrees/gate-unhook-docquery-2026-09-24 | server+docquery | in_progress | diff --git a/.doc/ops/short-term/2026-09-24-admin-password-reset.md b/.doc/ops/short-term/2026-09-24-admin-password-reset.md new file mode 100644 index 00000000..bd57ba6a --- /dev/null +++ b/.doc/ops/short-term/2026-09-24-admin-password-reset.md @@ -0,0 +1,37 @@ +# admin-password-reset-2026-09-24 + +| 字段 | 值 | +|---|---| +| status | in_progress | +| write_scope | internal, web, deploy/postgres/goose, .doc | +| mutex | auth+env:test | +| base_dev_sha | 3de5bc5c396fc21887f0f19e6e3baa8784347f99 | +| review_state | pending | +| integration_state | pending | +| deployment | not_required(合入 local_dev;晋升 test 另授权) | +| cleanup | pending | +| remote_sync | not_requested | +| policy_revision | AGENTS.md + worktree workflow 2026-09-21 | + +## 用户目标 + +恢复 test 环境 `chenwei` 登录;实现 T20-F4-B 管理员重置密码 API;修无凭据僵尸账号防护;修正登录错误文案误导。 + +## 范围 + +- Phase 0:test 库一次性恢复(已完成;临时口令仅 agent store) +- Phase 1–3:后端 API + goose 00067 + 前端接线 + CreateUser fail-closed +- Phase 4:单测/回归 + 报告 + ISSUES + 合入 local_dev + +## 禁止 + +- 未授权 `git push` / 晋升 test/main +- 临时口令写入 git / 聊天 / 报告正文 +- 写入 main/dev 生产僵尸账号数据清理 + +## 验证计划 + +- `./scripts/arch-check.sh --mode block`;`go vet ./...`;`go test -short ./...` +- `web npm test` / `npm run build` +- 新增 AdminResetPassword 正负例与 authErrorMessage 单测 +- Phase 0 实测 login 200(证据不披露口令) diff --git a/.doc/test-reports/2026-09-24-admin-password-reset-test-report.md b/.doc/test-reports/2026-09-24-admin-password-reset-test-report.md new file mode 100644 index 00000000..7ebc0772 --- /dev/null +++ b/.doc/test-reports/2026-09-24-admin-password-reset-test-report.md @@ -0,0 +1,74 @@ +# 管理员重置密码与登录凭据修复 测试报告 + +## 一、 测试背景 (Background) + +test 环境账号 `chenwei` 登录失败,表现为 `code=AUTHZ` / `message=无权访问` / `error=用户名或密码错误`。管理员 UI「重置密码」无法生效。根因调查确认:验收期曾重置哈希且不披露口令;重置按钮为 T20-F4 死功能;前端把信封 JSON 原文展示给用户。 + +## 二、 测试目标 (Test Objectives) + +- Phase 0:恢复 `chenwei` 可登录且 `must_change_password=true` +- Admin 重置 API:成功路径 + 非管理员 403 + 目标不存在 4xx + 旧会话失效 +- 登录错误文案优先领域 `error`,不再展示裸 JSON / 「无权访问」误导 +- CreateUser 拒绝空哈希;空哈希登录统一 401 +- Go short 全量与前端测试通过 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS: Linux;Go short;web npm test/build +- test 环境:`http://127.0.0.1:8081`,schema 65(运行时);代码侧新增 goose 00067(合入后待迁移) +- **NOT REQUIRED**:nightly 真实 PI 全链路(依据:本变更为 IAM/管理台,非产品 PI / 工作流模板 / 提示词;见 PROJECT.md §8 与测试报告规范「纯工程」例外说明) +- **NOT REQUIRED(本报告)**:test 环境 live `reset-password` API 冒烟 — 代码尚未晋升/重建到 test 容器;以 httptest 集成测 + Phase 0 SQL 恢复为验收。晋升后应补跑 live API。 + +证据目录:[assets/2026-09-24-admin-password-reset/](./assets/2026-09-24-admin-password-reset/) + +## 四、 预期结果 (Expected Results) + +| 用例 | 预期 | +|---|---| +| chenwei 临时口令登录 | 200,token,must_change_password=true | +| AdminResetPassword | 200,返回 temp_password;旧密 401;旧会话 401 | +| 非管理员重置 | 403 | +| authErrorMessage | 「用户名或密码错误」,无 `{`,非「无权访问」 | +| CreateUser 空哈希 | 错误 `password hash required` | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:Phase 0 chenwei 恢复登录 + +- **输入**:test 库 UPDATE credentials + must_change;`POST /api/v1/auth/login` +- **实际输出**:见 `assets/.../phase0-login-verify.txt`:`HTTP 200` / `LOGIN_OK` / `must_change_password True`;`password_changed_at` 日期 2026-09-24 +- **判定**:PASS(口令不入报告,路径仅 agent store) + +### 用例 2:AdminResetPassword 集成测 + +- **输入**:`go test -short -run AdminReset|... ./internal/auth/ ./internal/server/` +- **实际输出**:见 `assets/.../go-focused.txt`:`ok auth` / `ok server` +- **判定**:PASS + +### 用例 3:前端信封解析 + +- **输入**:`authErrorMessage` 单测 + `npm test` +- **实际输出**:497 + 34 vitest PASS;`authErrorMessage` 断言通过 +- **判定**:PASS + +### 用例 4:三环境凭据盘点(只读) + +- **输入**:SQL 聚合 +- **实际输出**:见 `cred-inventory.txt` — dev 263 missing / main 3 seed missing / test 0 +- **判定**:PASS(盘点);存量清理不在本任务 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +```text +./scripts/arch-check.sh --mode block # PASS +go vet ./... # PASS +go test -short ./... # PASS(server ~18s) +cd web && npm test # 497 + 34 PASS +cd web && npm run build # PASS +``` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- 达到合入 local_dev 标准。 +- 合入后:授权晋升 test → 迁移 00067 → rebuild → 用管理台真实重置 API 验收。 +- 存量僵尸账号清理需独立授权;main seed 三行非业务账号。 diff --git a/.doc/test-reports/assets/2026-09-24-admin-password-reset/cred-inventory.txt b/.doc/test-reports/assets/2026-09-24-admin-password-reset/cred-inventory.txt new file mode 100644 index 00000000..16630ef6 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-24-admin-password-reset/cred-inventory.txt @@ -0,0 +1,19 @@ +=== INV inventory 2026-09-24T04:15:07Z === +--- dev --- + total_users | missing_cred | empty_username +-------------+--------------+---------------- + 416 | 263 | 255 +(1 row) + +--- test --- + total_users | missing_cred | empty_username +-------------+--------------+---------------- + 9 | 0 | 0 +(1 row) + +--- main --- + total_users | missing_cred | empty_username +-------------+--------------+---------------- + 4 | 3 | 0 +(1 row) + diff --git a/.doc/test-reports/assets/2026-09-24-admin-password-reset/go-focused.txt b/.doc/test-reports/assets/2026-09-24-admin-password-reset/go-focused.txt new file mode 100644 index 00000000..27476091 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-24-admin-password-reset/go-focused.txt @@ -0,0 +1,2 @@ +ok chat-agent-system/internal/auth 0.500s +ok chat-agent-system/internal/server 0.852s diff --git a/.doc/test-reports/assets/2026-09-24-admin-password-reset/phase0-login-verify.txt b/.doc/test-reports/assets/2026-09-24-admin-password-reset/phase0-login-verify.txt new file mode 100644 index 00000000..a85cff3a --- /dev/null +++ b/.doc/test-reports/assets/2026-09-24-admin-password-reset/phase0-login-verify.txt @@ -0,0 +1,8 @@ +HTTP 200 +username chenwei +user_id 35 +must_change_password True +has_token True +LOGIN_OK +password_redacted=true +store_path=/run/user/1000/cursor_agent_stores/2a07f859-5ca4-479f-b61d-b5c62ea42dee/files/chenwei-test-temp-password.json diff --git a/deploy/postgres/goose/00067_2026-09-24-user-password-reset-audit.sql b/deploy/postgres/goose/00067_2026-09-24-user-password-reset-audit.sql new file mode 100644 index 00000000..4be62fed --- /dev/null +++ b/deploy/postgres/goose/00067_2026-09-24-user-password-reset-audit.sql @@ -0,0 +1,20 @@ +-- +goose Up +-- Admin password reset audit trail (no password or hash stored). +CREATE TABLE IF NOT EXISTS user_password_reset_audits ( + id BIGSERIAL PRIMARY KEY, + target_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + actor_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + request_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_user_password_reset_audits_target_created + ON user_password_reset_audits (target_user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_user_password_reset_audits_actor_created + ON user_password_reset_audits (actor_user_id, created_at DESC); + +-- +goose Down +DROP INDEX IF EXISTS idx_user_password_reset_audits_actor_created; +DROP INDEX IF EXISTS idx_user_password_reset_audits_target_created; +DROP TABLE IF EXISTS user_password_reset_audits; diff --git a/internal/auth/admin_reset_password_test.go b/internal/auth/admin_reset_password_test.go new file mode 100644 index 00000000..b9f43369 --- /dev/null +++ b/internal/auth/admin_reset_password_test.go @@ -0,0 +1,103 @@ +package auth_test + +import ( + "strings" + "testing" + + "chat-agent-system/internal/auth" + "chat-agent-system/internal/config" +) + +func TestGenerateTempPassword(t *testing.T) { + pw, err := auth.GenerateTempPassword(16) + if err != nil { + t.Fatal(err) + } + if len(pw) != 16 { + t.Fatalf("len=%d", len(pw)) + } + for _, r := range pw { + if !strings.ContainsRune("ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789", r) { + t.Fatalf("unexpected rune %q", r) + } + } +} + +func TestCreateUserRejectsEmptyPasswordHash(t *testing.T) { + store := auth.NewMemoryStore(&config.Config{Env: "dev", AllowInMemory: true}) + _, err := store.CreateUser(t.Context(), &auth.UserAccount{ + Username: "nohash", + Name: "无哈希", + Status: auth.StatusActive, + RoleID: "role-guest", + }) + if err == nil || !strings.Contains(err.Error(), "password hash required") { + t.Fatalf("expected password hash required, got %v", err) + } +} + +func TestAdminResetPassword_Service(t *testing.T) { + cfg := &config.Config{Env: "dev", AllowInMemory: true, AdminUsername: "admin", AdminPassword: "admin123"} + store := auth.NewMemoryStore(cfg) + svc := auth.NewService(store, nil, cfg) + + created, err := svc.CreateUserByAdmin(t.Context(), auth.CreateUserRequest{ + Username: "svc_reset", + Name: "服务重置", + InitialPassword: "InitPass123", + RoleID: "role-engineer", + Position: "方案工程师", + }) + if err != nil { + t.Fatal(err) + } + + result, err := svc.AdminResetPassword(t.Context(), created.ID, 1, "req_test") + if err != nil { + t.Fatal(err) + } + if result.TempPassword == "" || !result.MustChangePassword { + t.Fatalf("bad result %+v", result) + } + + _, err = svc.Login(t.Context(), "svc_reset", "InitPass123") + if err == nil { + t.Fatal("old password should fail") + } + session, err := svc.Login(t.Context(), "svc_reset", result.TempPassword) + if err != nil { + t.Fatal(err) + } + if !session.MustChangePassword { + t.Fatal("expected must_change_password") + } +} + +func TestLoginMissingPasswordHash(t *testing.T) { + cfg := &config.Config{Env: "dev", AllowInMemory: true, AdminUsername: "admin", AdminPassword: "admin123"} + store := auth.NewMemoryStore(cfg) + // Bypass CreateUser guard by mutating seeded user hash after create via UpdateUserPassword empty is not allowed; + // plant via direct store map is not exported — use CreateUser with hash then clear via UpdateUserPassword with invalid. + // Instead: create with hash then overwrite stored hash through UpdateUserPassword with a sentinel that CheckPassword rejects, + // and separately verify empty-hash path by creating through internal seed is hard. + // Use Login against unknown user for 401 uniformity, and empty-hash via GetUserByID mutation on MemoryStore UpdateUserPassword + // with hash "" is allowed by UpdateUserPassword — plant empty hash that way. + created, err := store.CreateUser(t.Context(), &auth.UserAccount{ + Username: "emptyhash", + Name: "空哈希", + PasswordHash: "placeholder", + Status: auth.StatusActive, + RoleID: "role-guest", + }) + if err != nil { + t.Fatal(err) + } + if _, err := store.UpdateUserPassword(t.Context(), created.ID, "", false); err != nil { + t.Fatal(err) + } + svc := auth.NewService(store, nil, cfg) + _, err = svc.Login(t.Context(), "emptyhash", "anything") + if err == nil || err.Error() != "用户名或密码错误" { + t.Fatalf("expected unified credential error, got %v", err) + } +} \ No newline at end of file diff --git a/internal/auth/crypto.go b/internal/auth/crypto.go index 7927a1df..454a21e8 100644 --- a/internal/auth/crypto.go +++ b/internal/auth/crypto.go @@ -1,9 +1,16 @@ package auth import ( + "crypto/rand" + "fmt" + "math/big" + "golang.org/x/crypto/bcrypt" ) +// tempPasswordAlphabet excludes ambiguous characters (0/O, 1/l/I). +const tempPasswordAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789" + // HashPassword 使用 bcrypt 对明文密码进行安全加盐哈希 func HashPassword(password string) (string, error) { bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) @@ -18,3 +25,20 @@ func CheckPassword(hashed, password string) bool { err := bcrypt.CompareHashAndPassword([]byte(hashed), []byte(password)) return err == nil } + +// GenerateTempPassword returns a cryptographically random temporary password. +func GenerateTempPassword(length int) (string, error) { + if length <= 0 { + length = 16 + } + out := make([]byte, length) + max := big.NewInt(int64(len(tempPasswordAlphabet))) + for i := range out { + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", fmt.Errorf("generate temp password: %w", err) + } + out[i] = tempPasswordAlphabet[n.Int64()] + } + return string(out), nil +} diff --git a/internal/auth/pg_store.go b/internal/auth/pg_store.go index 75589f14..3fb5ff9a 100644 --- a/internal/auth/pg_store.go +++ b/internal/auth/pg_store.go @@ -132,6 +132,9 @@ func (s *PgStore) CreateUser(ctx context.Context, user *UserAccount) (*UserAccou if user == nil || strings.TrimSpace(user.Username) == "" { return nil, errors.New("username required") } + if strings.TrimSpace(user.PasswordHash) == "" { + return nil, errors.New("password hash required") + } tx, err := s.pool.Begin(ctx) if err != nil { return nil, err @@ -161,14 +164,12 @@ func (s *PgStore) CreateUser(ctx context.Context, user *UserAccount) (*UserAccou return nil, fmt.Errorf("insert user: %w", err) } - if user.PasswordHash != "" { - _, err = tx.Exec(ctx, ` - INSERT INTO user_credentials (user_id, password_hash) - VALUES ($1, $2) - `, id, user.PasswordHash) - if err != nil { - return nil, fmt.Errorf("insert credentials: %w", err) - } + _, err = tx.Exec(ctx, ` + INSERT INTO user_credentials (user_id, password_hash) + VALUES ($1, $2) + `, id, user.PasswordHash) + if err != nil { + return nil, fmt.Errorf("insert credentials: %w", err) } if user.RoleID != "" { @@ -315,6 +316,14 @@ func (s *PgStore) UpdateUserPassword(ctx context.Context, id int64, passwordHash return s.GetUserByID(ctx, id) } +func (s *PgStore) RecordPasswordResetAudit(ctx context.Context, targetUserID, actorUserID int64, requestID string) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO user_password_reset_audits (target_user_id, actor_user_id, request_id) + VALUES ($1, $2, NULLIF($3, '')) + `, targetUserID, actorUserID, strings.TrimSpace(requestID)) + return err +} + func (s *PgStore) ListUsers(ctx context.Context) ([]*UserAccount, error) { rows, err := s.pool.Query(ctx, userSelectSQL+` ORDER BY u.id ASC`) if err != nil { diff --git a/internal/auth/service.go b/internal/auth/service.go index fb5d58e2..4166b569 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "log/slog" "strings" "sync" @@ -83,6 +84,11 @@ func (s *Service) Login(ctx context.Context, username, password string) (*Sessio return nil, errors.New("用户名或密码错误") } + if strings.TrimSpace(user.PasswordHash) == "" { + slog.Warn("auth_login_missing_password_hash", "user_id", user.ID) + return nil, errors.New("用户名或密码错误") + } + if !CheckPassword(user.PasswordHash, password) { return nil, errors.New("用户名或密码错误") } @@ -193,6 +199,48 @@ func (s *Service) ChangePassword(ctx context.Context, userID int64, oldPass, new return err } +// AdminResetPasswordResult is returned once to the calling admin; plaintext must not be logged. +type AdminResetPasswordResult struct { + TempPassword string `json:"temp_password"` + MustChangePassword bool `json:"must_change_password"` + UserID int64 `json:"user_id"` +} + +// AdminResetPassword generates a temporary password, persists its hash with +// must_change_password=true, revokes existing sessions, and records an audit row. +func (s *Service) AdminResetPassword(ctx context.Context, targetID, actorID int64, requestID string) (*AdminResetPasswordResult, error) { + if targetID <= 0 { + return nil, errors.New("无效的用户ID") + } + if actorID <= 0 { + return nil, errors.New("无效的操作者") + } + if _, err := s.store.GetUserByID(ctx, targetID); err != nil { + return nil, errors.New("用户不存在") + } + + temp, err := GenerateTempPassword(16) + if err != nil { + return nil, fmt.Errorf("生成临时口令失败: %w", err) + } + hash, err := HashPassword(temp) + if err != nil { + return nil, fmt.Errorf("临时口令加密失败: %w", err) + } + if _, err := s.store.UpdateUserPassword(ctx, targetID, hash, true); err != nil { + return nil, err + } + _ = s.sessions.RevokeAllForUser(ctx, targetID) + if err := s.store.RecordPasswordResetAudit(ctx, targetID, actorID, requestID); err != nil { + return nil, fmt.Errorf("记录重置审计失败: %w", err) + } + return &AdminResetPasswordResult{ + TempPassword: temp, + MustChangePassword: true, + UserID: targetID, + }, nil +} + // GetUserByToken 根据会话 Token 查找登录用户 func (s *Service) GetUserByToken(ctx context.Context, token string) (*UserAccount, error) { userID, err := s.sessions.ResolveUserID(ctx, HashToken(token)) diff --git a/internal/auth/store.go b/internal/auth/store.go index 2e3b8ab7..ca7c4ccc 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -19,6 +19,7 @@ type AuthStore interface { UpdateUserRole(ctx context.Context, id int64, roleID, position string) (*UserAccount, error) UpdateUserStatus(ctx context.Context, id int64, status AccountStatus) (*UserAccount, error) UpdateUserPassword(ctx context.Context, id int64, passwordHash string, mustChangePassword bool) (*UserAccount, error) + RecordPasswordResetAudit(ctx context.Context, targetUserID, actorUserID int64, requestID string) error ListUsers(ctx context.Context) ([]*UserAccount, error) ListActiveUserDirectory(ctx context.Context) ([]UserDirectoryEntry, error) GetConfig(ctx context.Context) (SystemAuthConfig, error) @@ -155,6 +156,10 @@ func (s *MemoryStore) CreateUser(ctx context.Context, user *UserAccount) (*UserA s.mu.Lock() defer s.mu.Unlock() + if user == nil || strings.TrimSpace(user.PasswordHash) == "" { + return nil, fmt.Errorf("password hash required") + } + for _, existing := range s.users { if strings.EqualFold(existing.Username, user.Username) { return nil, fmt.Errorf("username already exists: %s", user.Username) @@ -176,6 +181,11 @@ func (s *MemoryStore) CreateUser(ctx context.Context, user *UserAccount) (*UserA return &result, nil } +// RecordPasswordResetAudit records an admin password reset in memory (no persistence). +func (s *MemoryStore) RecordPasswordResetAudit(ctx context.Context, targetUserID, actorUserID int64, requestID string) error { + return nil +} + // UpdateUser 更新用户基础资料;actorID==user.ID 时清除管理员代改标记。 func (s *MemoryStore) UpdateUser(ctx context.Context, user *UserAccount, actorID int64) (*UserAccount, error) { s.mu.Lock() diff --git a/internal/server/auth_handlers.go b/internal/server/auth_handlers.go index e5273b1e..7b14f8b3 100644 --- a/internal/server/auth_handlers.go +++ b/internal/server/auth_handlers.go @@ -10,6 +10,7 @@ import ( "chat-agent-system/internal/auth" "chat-agent-system/internal/authz" + "chat-agent-system/internal/httpx" ) // authIsInfrastructureError classifies DB/driver/tx failures that must not be @@ -383,6 +384,41 @@ func (s *Server) handleAdminUpdateStatus(w http.ResponseWriter, r *http.Request) _ = json.NewEncoder(w).Encode(updated) } +// handleAdminResetPassword POST /api/v1/admin/users/{id}/reset-password +// Returns a one-time temporary password; must not be logged. +func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireSystemAdmin(w, r) + if !ok { + return + } + if r.Method != http.MethodPost { + writeSafeError(w, r, http.StatusMethodNotAllowed, "不支持的请求方法") + return + } + + targetID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || targetID <= 0 { + writeSafeError(w, r, http.StatusBadRequest, "无效的用户ID") + return + } + + result, err := s.authSvc.AdminResetPassword(r.Context(), targetID, actor.ID, httpx.RequestID(r)) + if err != nil { + msg := err.Error() + status := http.StatusBadRequest + if strings.Contains(msg, "不存在") { + status = http.StatusNotFound + } else if authIsInfrastructureError(err) { + status = http.StatusInternalServerError + } + writeSafeError(w, r, status, msg) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(result) +} + // handleAuthPublicConfig 返回登录/注册页所需的公开准入配置(无需鉴权) func (s *Server) handleAuthPublicConfig(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/internal/server/auth_handlers_test.go b/internal/server/auth_handlers_test.go index 064c7337..e9508d98 100644 --- a/internal/server/auth_handlers_test.go +++ b/internal/server/auth_handlers_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strconv" "testing" "chat-agent-system/internal/auth" @@ -285,3 +286,114 @@ func TestAuthHandlers_AdminCreateUser_MustChangePassword(t *testing.T) { t.Errorf("Expected MustChangePassword to be false after changing password") } } + +func TestAuthHandlers_AdminResetPassword(t *testing.T) { + ts, authSvc := setupTestServer(t) + client := &http.Client{} + + adminLoginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "admin123"}) + adminResp, err := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(adminLoginBody)) + if err != nil { + t.Fatalf("admin login: %v", err) + } + var adminSession auth.SessionInfo + _ = json.NewDecoder(adminResp.Body).Decode(&adminSession) + adminResp.Body.Close() + if adminSession.Token == "" { + t.Fatal("admin token empty") + } + + created, err := authSvc.CreateUserByAdmin(t.Context(), auth.CreateUserRequest{ + Username: "reset_target", + Name: "重置目标", + InitialPassword: "OldPassword111", + RoleID: "role-engineer", + Position: "方案工程师", + }) + if err != nil { + t.Fatalf("create target: %v", err) + } + + oldLoginBody, _ := json.Marshal(map[string]string{"username": "reset_target", "password": "OldPassword111"}) + oldLoginResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(oldLoginBody)) + var oldSession auth.SessionInfo + _ = json.NewDecoder(oldLoginResp.Body).Decode(&oldSession) + oldLoginResp.Body.Close() + if oldSession.Token == "" { + t.Fatal("target old session empty") + } + + resetReq, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/admin/users/"+itoa64(created.ID)+"/reset-password", bytes.NewReader([]byte("{}"))) + resetReq.Header.Set("Authorization", "Bearer "+adminSession.Token) + resetReq.Header.Set("Content-Type", "application/json") + resetResp, err := client.Do(resetReq) + if err != nil { + t.Fatalf("reset request: %v", err) + } + defer resetResp.Body.Close() + if resetResp.StatusCode != http.StatusOK { + t.Fatalf("reset status=%d", resetResp.StatusCode) + } + var resetBody auth.AdminResetPasswordResult + if err := json.NewDecoder(resetResp.Body).Decode(&resetBody); err != nil { + t.Fatalf("decode reset: %v", err) + } + if resetBody.TempPassword == "" || !resetBody.MustChangePassword || resetBody.UserID != created.ID { + t.Fatalf("unexpected reset body: %+v", resetBody) + } + + // Old password no longer works. + badResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(oldLoginBody)) + if badResp.StatusCode != http.StatusUnauthorized { + t.Fatalf("old password expected 401, got %d", badResp.StatusCode) + } + badResp.Body.Close() + + // Old session revoked. + meReq, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/auth/me", nil) + meReq.Header.Set("Authorization", "Bearer "+oldSession.Token) + meResp, _ := client.Do(meReq) + if meResp.StatusCode != http.StatusUnauthorized { + t.Fatalf("old session expected 401, got %d", meResp.StatusCode) + } + meResp.Body.Close() + + // Temp password works and forces change. + tempLoginBody, _ := json.Marshal(map[string]string{"username": "reset_target", "password": resetBody.TempPassword}) + tempResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(tempLoginBody)) + var tempSession auth.SessionInfo + _ = json.NewDecoder(tempResp.Body).Decode(&tempSession) + tempResp.Body.Close() + if tempResp.StatusCode != http.StatusOK || !tempSession.MustChangePassword { + t.Fatalf("temp login status=%d must_change=%v", tempResp.StatusCode, tempSession.MustChangePassword) + } + + // Non-admin forbidden. + memberLoginBody, _ := json.Marshal(map[string]string{"username": "zhanggong", "password": "123456"}) + memberResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(memberLoginBody)) + var memberSession auth.SessionInfo + _ = json.NewDecoder(memberResp.Body).Decode(&memberSession) + memberResp.Body.Close() + denyReq, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/admin/users/"+itoa64(created.ID)+"/reset-password", bytes.NewReader([]byte("{}"))) + denyReq.Header.Set("Authorization", "Bearer "+memberSession.Token) + denyReq.Header.Set("Content-Type", "application/json") + denyResp, _ := client.Do(denyReq) + if denyResp.StatusCode != http.StatusForbidden { + t.Fatalf("non-admin expected 403, got %d", denyResp.StatusCode) + } + denyResp.Body.Close() + + // Missing user. + missingReq, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/admin/users/999999/reset-password", bytes.NewReader([]byte("{}"))) + missingReq.Header.Set("Authorization", "Bearer "+adminSession.Token) + missingReq.Header.Set("Content-Type", "application/json") + missingResp, _ := client.Do(missingReq) + if missingResp.StatusCode != http.StatusNotFound && missingResp.StatusCode != http.StatusBadRequest { + t.Fatalf("missing user expected 404/400, got %d", missingResp.StatusCode) + } + missingResp.Body.Close() +} + +func itoa64(v int64) string { + return strconv.FormatInt(v, 10) +} diff --git a/internal/server/server.go b/internal/server/server.go index 11320e8f..5854a95d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -348,6 +348,7 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("PUT /api/v1/admin/users/{id}/role", s.handleAdminUpdateRole) mux.HandleFunc("PUT /api/v1/admin/users/{id}/status", s.handleAdminUpdateStatus) mux.HandleFunc("PATCH /api/v1/admin/users/{id}/profile", s.handleAdminUpdateProfile) + mux.HandleFunc("POST /api/v1/admin/users/{id}/reset-password", s.handleAdminResetPassword) mux.HandleFunc("GET /api/v1/admin/config", s.handleAdminConfig) mux.HandleFunc("PUT /api/v1/admin/config", s.handleAdminConfig) diff --git a/web/src/api/auth.test.ts b/web/src/api/auth.test.ts index 0efb65f4..d6558768 100644 --- a/web/src/api/auth.test.ts +++ b/web/src/api/auth.test.ts @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createInMemoryAuthClient } from './auth.local'; +import { authErrorMessage } from './auth'; test('AuthClient: 基础登录与 .env 超级管理员鉴权', async () => { const client = createInMemoryAuthClient({ @@ -112,3 +113,12 @@ test('AuthClient: 管理员创建账号必须在首次登录修改密码', async const updatedSession = await client.login({ username: 'lead_zhao', password: 'NewPermanentPassword888' }); assert.equal(updatedSession.mustChangePassword, false); }); + +test('authErrorMessage: 优先信封 error 领域原因,避免裸 JSON / 无权访问误导', () => { + const body = + '{"code":"AUTHZ","message":"无权访问","request_id":"req_x","error":"用户名或密码错误"}'; + const msg = authErrorMessage({ code: 'AUTHZ', message: '无权访问' }, body, '用户名或密码错误'); + assert.equal(msg, '用户名或密码错误'); + assert.ok(!msg.includes('{')); + assert.notEqual(msg, '无权访问'); +}); diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index 46672c35..5b2f78e4 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -11,10 +11,31 @@ import { } from '../types/auth'; import { RoleDefinition } from '../types/rbac'; import { mapLoginResult, mapMeResult, mapUserAccount, mapUserAccountList } from './authUserMap'; -import { apiFetch } from './client'; +import { apiErrorFromBody, apiFetch } from './client'; const AUTH_STORAGE_KEY_SESSION = 'smdf_auth_session'; +/** Prefer domain error text over envelope SafeMessage ("无权访问"). */ +export function authErrorMessage( + err: { code: string; message: string }, + bodyText: string, + fallback: string, +): string { + try { + const parsed = JSON.parse(bodyText) as { error?: string; code?: string; message?: string }; + const domain = typeof parsed.error === 'string' ? parsed.error.trim() : ''; + if (domain && domain[0] !== '{' && domain !== parsed.code && domain !== err.code) { + return domain; + } + } catch { + // fall through + } + if (err.message && err.message !== '无权访问' && err.message[0] !== '{') { + return err.message; + } + return fallback; +} + export interface AuthClientOptions { adminUsername?: string; adminPassword?: string; @@ -163,7 +184,8 @@ export class AuthClient { }); if (!resp.ok) { const errText = await resp.text(); - throw new Error(errText.trim() || '用户名或密码错误'); + const apiErr = apiErrorFromBody(resp.status, errText, '用户名或密码错误'); + throw new Error(authErrorMessage(apiErr, errText, '用户名或密码错误')); } const data = mapLoginResult(await resp.json()); this.saveSession(data); @@ -225,8 +247,33 @@ export class AuthClient { return this.getAdminUsers(); } - async adminResetPassword(_userId: number, _tempPass: string): Promise { - throw new Error('请通过后端管理员接口重置密码'); + async adminResetPassword(userId: number): Promise<{ tempPassword: string; mustChangePassword: boolean }> { + const token = this.getSessionToken(); + const resp = await authRequest(`/api/v1/admin/users/${userId}/reset-password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: '{}', + }, true); + if (!resp.ok) { + const errText = await resp.text(); + const apiErr = apiErrorFromBody(resp.status, errText, '重置密码失败'); + throw new Error(authErrorMessage(apiErr, errText, '重置密码失败')); + } + const data = (await resp.json()) as { + temp_password?: string; + must_change_password?: boolean; + }; + const tempPassword = String(data.temp_password ?? '').trim(); + if (!tempPassword) { + throw new Error('重置成功但未返回临时口令,请联系运维'); + } + return { + tempPassword, + mustChangePassword: data.must_change_password !== false, + }; } async createAdminUser(payload: CreateAdminUserPayload): Promise { diff --git a/web/src/components/admin/console/sections/UsersSection.tsx b/web/src/components/admin/console/sections/UsersSection.tsx index 281b83be..d8ef8661 100644 --- a/web/src/components/admin/console/sections/UsersSection.tsx +++ b/web/src/components/admin/console/sections/UsersSection.tsx @@ -180,21 +180,33 @@ export const UsersSection: React.FC = ({ currentUser, onRefre } }; - // 4. 重置用户密码为初始并强制改密 + // 4. 重置用户密码:服务端生成一次性临时口令并强制改密 const handleResetUserPassword = async (user: UserAccount) => { - const tempPass = 'ResetPass123'; - if (!window.confirm(`确定要重置【${user.name}】的密码吗?重置后初始密码为 ${tempPass},该用户下次登录将被强制改密。`)) return; - try { - await authClient.adminResetPassword(user.id, tempPass); - } catch (err: any) { - showNotification('error', err.message || '重置密码失败'); + if ( + !window.confirm( + `确定要重置【${user.name}】的密码吗?系统将生成一次性临时口令,该用户下次登录将被强制改密。临时口令仅展示一次。`, + ) + ) { return; } try { - await refreshUsers(); - showNotification('success', `已重置【${user.name}】密码为 ${tempPass},已标记首次登录强制改密`); - } catch { - showNotification('error', '密码已重置,但全局用户目录同步失败,请刷新后重试'); + const result = await authClient.adminResetPassword(user.id); + try { + await refreshUsers(); + } catch { + // password already reset; continue to show temp password + } + const copied = window.prompt( + `已重置【${user.name}】密码。请立即复制临时口令(关闭后不可再查看):`, + result.tempPassword, + ); + if (copied === null) { + showNotification('success', '密码已重置;请手动记下上方弹出的临时口令'); + } else { + showNotification('success', '密码已重置,已标记首次登录强制改密'); + } + } catch (err: any) { + showNotification('error', err.message || '重置密码失败'); } };