修复认证挑战与可信代理安全边界(07-17-auth-challenge-trusted-proxy-hardening)

这个提交包含在:
2026-07-17 14:24:58 +08:00
父节点 bbb5c89825
当前提交 6ddedf1e0e
共修改 31 个文件,包含 3801 行新增和 419 行删除
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,73 @@
# 认证验证码与可信代理安全设计
## Security Invariants
1. 同一账号同一时刻最多一枚可验证的密码重置码。
2. 失败次数与账号绑定,不与 IP、单个验证码或应用进程绑定。
3. 验证码只能原子消费一次;密码变化与会话撤销不可拆分。
4. 应用只信任实际反向代理生成的客户端地址。
5. 未知账号、停用账号、锁定账号和无效验证码不能形成账号枚举信号。
6. 已用旧密码完成事务外校验的并发登录,不得在密码重置提交后创建可刷新的新会话。
## Data Design
- `PasswordResetCode` 增加 `invalidated_at` 与 `attempts_count`,验证码查询必须要求未使用、未失效、未过期。
- `Employee` 增加密码恢复专用失败计数、失败窗口、锁定截止、申请计数、申请窗口和最近申请时间;不复用密码登录失败字段。
- `Employee` 同时增加邮箱验证码登录的账号级失败计数、失败窗口、锁定截止和最近申请时间;重新申请不得绕过累计失败边界。
- 第一版不增加 partial unique index。申请事务通过员工行 `FOR UPDATE` 串行化并先失效旧码;验证事务通过同一账号锁和条件更新确保单次消费。
- 迁移将所有现有未使用密码重置码标记为失效,避免弱历史码继续生效。
- 历史 migration 对 `password_reset_codes.employee_id` 使用 `ON DELETE CASCADE`,而当前 schema 声明 `Restrict`;这是本任务之前已经存在的漂移。本次手写 additive migration 不改外键,最终删除语义另行裁决,禁止由 `migrate dev` 静默夹带修复。
## Request Flow
1. 规范化邮箱并查找活跃员工。
2. 对未知/停用账号直接返回通用成功;不得写含邮箱的诊断日志。
3. 事务锁员工行,读取恢复状态。
4. 若处于账号锁、冷却或申请窗口上限,只更新必要的安全审计并返回通用成功。
5. 失效旧码,生成 CSPRNG 验证码和用途域分离 HMAC,创建唯一当前码,原子更新申请状态。
6. 事务提交后发送通知;外部邮件失败不回滚安全状态,也不泄露账号存在性。
## Verify Flow
1. 使用相同的通用错误处理未知、停用、锁定、过期和错误码。
2. 事务锁员工行并读取当前有效验证码。
3. 使用 timing-safe 比较 HMAC;失败时原子累计账号和验证码尝试,达到阈值设置锁定并失效验证码。
4. 成功时以 `used_at IS NULL AND invalidated_at IS NULL` 条件消费,要求更新数为 1。
5. 在同一事务内更新密码、增加 token 版本、撤销 refresh session、失效该账号所有恢复码和邮箱登录码、写成功审计。
## Email Login Flow
1. 申请与验证均先锁员工行;申请受账号冷却和邮箱验证码专用锁控制,并在创建新码前把旧未用码标记为已失效。
2. 验证失败在事务内同时累计账号和当前码次数;达到阈值时锁定账号级邮箱验证码入口,重新申请不清零。
3. 验证成功条件消费当前码、清理邮箱验证码失败状态、创建 refresh session 并写审计,事务提交后才设置 cookie。
4. 普通密码登录在创建 refresh session 前,以初读 `password_hash + token_version` 做条件更新;密码重置先提交时旧登录必须失败,登录先提交时其会话由随后重置撤销。
5. 用户主动改密和管理员重置密码同样撤销 refresh session、失效恢复码与邮箱验证码并清理账号级挑战状态,避免旧验证码覆盖新的密码安全事件。
## Trusted Proxy Design
- `getConfig()` 提供显式 proxy trust 配置;开发/测试默认 `false`,部署配置为受控的一跳或 CIDR。
- Fastify 构造使用解析后的值,禁止生产回落到 `true`。
- Caddy 是当前事实部署代理;Nginx 模板作为兼容配置同样覆盖 XFF。
- Compose 的 server 映射改为宿主回环地址;数据库和对象存储端口的进一步收口单独记录,不扩大本 task 的认证验收。
## Error And Audit Contract
- 申请接口始终返回相同成功 envelope。
- 验证失败统一返回 `VALID_001` 和稳定的 `invalid_or_expired`,不区分账号状态。
- 审计可记录 employee ID、计数、锁定结果和可信 IP,不记录邮箱、验证码、HMAC、密码或原始请求体。
- 生产配置不得记录验证码;启动期发现开发显示开关即拒绝启动。
## Migration And Rollback
- migration 只新增字段/索引并失效短期验证码,不删除业务数据。
- 发布顺序必须先在受控环境设置强 pepper 与单跳代理配置、由边缘暂停 `/api/auth/forgot-password`、`/api/auth/reset-password`、`/api/auth/email-login/code`、`/api/auth/email-login/verify` 四个 challenge 端点,再用新镜像执行配置预检和 additive migration,最后切换应用;不得先启动依赖新增列的新应用。
- 代码发布失败时可回滚应用,新增字段对旧版本无害;但边缘必须继续关闭上述四个端点,旧代码不能重新对外提供弱流程。
- 不执行 migrate reset,不在本任务连接或修改生产数据库。
## Test Matrix
- 申请:未知/停用/活跃、冷却、窗口上限、串行与并发、多实例语义。
- 验证:错误次数、锁定、锁后重申、过期、旧码、正确码并发消费、统一响应。
- 会话:旧 access/refresh、邮箱验证码、旧密码、新密码和审计。
- 代理:无代理、可信一跳、伪造单/多值 XFF、直连端口边界。
- 配置:生产缺 pepper、生产显示验证码、env contract 和回滚配置。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,126 @@
# 认证验证码与可信代理安全收口
## Goal
关闭密码重置的远程账号接管链,并使密码重置、邮箱验证码登录共用的随机源、账号级节流、并发消费和代理 IP 边界具备可执行的安全合同;所有改动保持小团队单机部署可维护、可回滚,不引入 Redis 或新的认证平台。
## Scope
- 密码重置验证码的生成、存储、申请、校验、消费、失败锁定和成功后的会话失效。
- 普通密码登录、用户主动改密和管理员重置密码与自助重置之间的并发/验证码失效边界。
- 邮箱登录验证码共用的密码学随机源、申请冷却和账号级失败边界,避免只修复一个六位码入口。
- Fastify `trustProxy`、反向代理 XFF、API 宿主端口绑定和相关环境合同。
- Prisma additive migration、聚焦单测、真实 PostgreSQL 并发回归脚本/测试以及部署前静态门禁。
## Approved Requirements
### 1. 密码学随机与安全存储
- 六位验证码统一使用 `crypto.randomInt(0, 1_000_000)`。
- 密码重置码使用带用途域分离的 HMAC;生产环境的验证码 pepper 必须配置且不得进入日志、响应或前端 bundle。
- `NODE_ENV=production` 时禁止启用任何显示或记录验证码的开发开关。
### 2. 单账号单有效码与申请节流
- 新的密码重置码必须在同一事务中使该账号所有旧未消费码失效。
- 事务先锁定员工行,再检查恢复锁、账号申请冷却和申请窗口,确保并发申请最终只有一枚有效码。
- 账号申请节流是数据库权威边界;IP 限流仅作外层防护,轮换 IP 不得绕过账号节流。
- 不存在、停用、锁定、冷却中的账号对申请端返回相同的通用成功结构,避免账号枚举和通知轰炸反馈。
### 3. 失败锁定与原子消费
- 密码恢复使用独立于密码登录的失败计数、失败窗口和锁定截止时间。
- 默认连续失败 5 次锁定恢复入口 30 分钟;重新申请验证码不能清零失败计数。
- 验证、失败累计、条件消费、密码更新、`token_version` 增加、refresh session 撤销、旧恢复码和未使用邮箱验证码失效必须在受锁事务内完成。
- 同一正确码并发提交时仅一个请求成功;其余请求返回通用无效/过期响应。
- 活跃、不存在、停用、锁定账号的无效验证响应保持同一 HTTP 状态和数据结构。
### 4. 可信代理和端口边界
- Fastify 不再使用无限制 `trustProxy: true`;开发默认不信任代理,部署环境只信任明确的一跳或配置的代理 CIDR。
- Caddy/Nginx 必须覆盖而不是追加不可信客户端 XFF;Nginx 模板不得使用会保留客户端链的配置。
- Compose API 端口只绑定宿主回环地址,阻止绕过反向代理直接访问。
- 可信 IP 修复作用于密码登录、邮箱验证码登录、忘记密码和重置密码全部认证限流入口。
### 5. 迁移、回滚与兼容
- 采用 additive migration:为密码恢复增加失效、失败、申请和锁定状态;不重写历史迁移。
- 部署迁移时统一使现有密码重置码失效,用户需要重新申请;不得保留旧弱验证码作为兼容。
- 第一版不依赖数据库 partial unique index,以员工行锁和条件更新保证不变量,保持旧应用能够启动。
- 发布迁移或回滚旧认证代码时,边缘必须继续关闭 `/api/auth/forgot-password`、`/api/auth/reset-password`、`/api/auth/email-login/code`、`/api/auth/email-login/verify` 四个 challenge 端点,直到安全版本恢复;API 端口不得重新公网暴露。
## Acceptance Criteria
- [x] 密码重置和邮箱登录验证码均使用 CSPRNG,密码重置码不再使用无密钥 SHA-256。
- [x] 顺序和并发重复申请后仅最新密码重置码有效,所有旧码失败。
- [x] 轮换 XFF/IP 的错误尝试仍按账号累计,达到阈值后锁定;重新申请不清零。
- [x] 20 个并发正确重置请求恰好一个成功,密码、`token_version` 和审计只产生一次有效状态迁移。
- [x] 已用旧密码完成校验的并发登录在重置先提交时不能创建 refresh session;登录先提交时其会话被重置事务撤销。
- [x] 重置成功后旧 access token、全部 refresh session、旧重置码和未使用邮箱登录码均不可用。
- [x] 用户主动改密和管理员重置密码同样撤销旧会话并失效未使用的两类验证码。
- [x] 活跃、不存在、停用、锁定账号的无效验证响应无可观察的结构差异。
- [x] 伪造 XFF 不能改变应用识别的可信客户端 IP,Compose API 不绑定所有宿主接口。
- [x] 生产配置启用验证码显示开关时应用 fail closed。
- [x] Prisma validate、部署环境合同、聚焦认证测试、真实 PostgreSQL 并发验证、Trellis governance 和 `git diff --check` 通过。
- [x] 独立只读 reviewer 复核认证、迁移、并发、代理和回滚,不修改代码。
## Definition of Done
- 两条已确认账号接管条件(多码穷举、并发重复消费)均由数据库可执行边界阻断。
- 认证限流不再信任任意客户端 XFF,部署端口不能绕过受控反向代理。
- 迁移和代码回滚路径明确,未访问生产数据库、未部署、未修改公网配置。
- 聚焦回归和项目门禁通过,改动以中文提交并引用本 task 名称。
## Decision (ADR-lite)
**Context**:现有实现的验证码随机、生命周期、失败锁和代理 IP 都不是独立安全边界;单独调低 IP 限流或只改随机源不能关闭接管链。
**Decision**:采用“员工行锁 + 旧码失效 + 账号级恢复状态 + 条件消费 + 显式可信代理”的最小方案。保留现有单机 PostgreSQL,不引入 Redis;认证各入口共享随机与代理规则,但密码重置和邮箱登录保留各自业务动作。
**Consequences**:现有未完成重置流程会在迁移时失效;新增少量 schema 和部署环境配置。换来跨进程一致的账号锁、可验证的单码不变量和明确的回滚边界。
## Implementation Plan
1. 配置与模型:增加恢复安全配置、Prisma 字段和兼容迁移,生产开发开关 fail closed。
2. 认证实现:抽取 CSPRNG/HMAC 与账号状态逻辑,重写申请和验证事务,补邮箱验证码相邻边界。
3. 代理与部署:显式 trust proxy、回环端口、Nginx XFF 覆盖和环境合同。
4. 验证:单元/路由测试、真实 PostgreSQL 双连接并发测试、静态门禁和只读安全 reviewer。
## Test Plan
- 语法与模型:`node --check` 覆盖认证、用户、配置、应用、密码学工具和并发编排器;`cd server && npm run prisma:validate`。
- 聚焦单测:认证路由、管理员重置、生产安全配置/可信代理、隔离并发 guard;覆盖 CSPRNG/HMAC、旧码失效、冷却、账号级锁定、统一错误、条件消费、会话撤销和旧密码登录竞态守卫。
- 部署静态合同:`node deploy/trialrun/validate-env-contract.js`、相关 shell `bash -n`,验证 API 回环绑定、Nginx 覆盖 XFF、强 pepper/单跳配置和“迁移先于应用切换”。
- 真实 PostgreSQL:`bash scripts/qa/run-web-gates.sh auth-security-local` 只创建 `hyb_oa_auth_*` loopback 临时库,运行 20 个并发申请、20 个同码消费、reset-first、login-first 和 refresh/reset 行锁竞态,结束后删除本次创建的临时库。
- 项目门禁:`node scripts/qa/check-trellis-governance.mjs`、`git diff --check` 和 `bash scripts/check-minimal.sh`;不连接生产数据库、不部署。
- 只读复核:独立 reviewer 检查认证、迁移、代理、发布顺序和回滚,不修改代码。
## Verification Evidence (2026-07-17)
- 聚焦测试:4 个认证相关 Vitest 文件,`61/61` 通过。
- 完整最小门禁:服务端 `155` 个测试文件、`831/831` 通过;前端 typecheck 与生产构建通过。
- 模型与部署:Prisma validate、59 个 migration 空库部署、部署环境合同、相关 Node/Shell 语法均通过。
- 隔离 PostgreSQL:并发申请、同码消费、reset-first、login-first、refresh/reset 五组场景全部通过;本轮临时数据库和一次性容器均确认删除。
- 治理与隐私:Trellis task validate、governance、process baseline、敏感内容扫描和 `git diff --check` 通过;文档注册审计为 `0` error,保留既有未注册文档 warning。
- 两轮独立只读 reviewer 的阻断项已吸收;最终 delta review 未发现剩余 P0,实现与部署合同仅保留下面列出的后续项。
- 本轮未部署、未连接现有或生产数据库、未修改公网/Caddy 配置,也未发送外部邮件或企业微信写入。
## Residual Follow-up
- 历史 password reset 外键删除语义与当前 Prisma schema 存在既有漂移;本次 migration 未夹带修复,后续单独裁决。
- 既有普通密码登录/通用 rate-limit 数值配置仍使用宽松解析,归入后续 P1 配置收口。
- 上线前需只读审计历史邮箱大小写重复,并由操作人完成四个 challenge 端点和 Caddy XFF 覆盖的 `EXTERNAL_GATED` 人工确认。
- 根目录开发 Compose 的端口暴露不属于正式 trialrun 合同,作为后续 P2 开发环境收口项处理。
## Out of Scope
- 公网配置修改、部署、生产数据库迁移、真实账号验证或日志取证。
- Redis/WAF/验证码服务平台、短信验证码、OIDC/企业微信登录架构重做。
- 采购附件 P0;该问题使用独立 Trellis task。
- 全站认证 UI 改版或密码策略重构。
## Technical Notes
- 审查证据:`.trellis/tasks/07-16-system-multidimensional-audit/research/business-security-review.md`。
- 主要实现范围:`server/src/routes/auth.js`、`server/src/app.js`、`server/src/config.js`、Prisma schema/migration、部署 Compose/Nginx 和认证测试。
- 用户已于 2026-07-17 批准上述 P0 方案;本 task 无剩余产品偏好问题。
@@ -0,0 +1,37 @@
{
"id": "auth-challenge-trusted-proxy-hardening",
"name": "auth-challenge-trusted-proxy-hardening",
"title": "认证验证码与可信代理安全收口",
"description": "修复密码重置多码并存、弱随机、账号级失败锁缺失、验证码并发重复消费及可信代理/IP 限流绕过,并同步收口邮箱验证码共用安全边界。",
"status": "in_progress",
"dev_type": null,
"scope": "security/auth-challenge-trusted-proxy",
"package": "server",
"priority": "P0",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-07-17",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
".trellis/tasks/07-16-system-multidimensional-audit/research/business-security-review.md",
".trellis/spec/hyb-oa/backend-fastify-prisma.md",
".trellis/spec/hyb-oa/security-privacy.md",
".trellis/spec/hyb-oa/deployment-trialrun.md",
"server/src/routes/auth.js",
"server/src/app.js",
"server/src/config.js",
"server/prisma/schema.prisma",
"deploy/trialrun/docker-compose.yml",
"deploy/trialrun/nginx-hyb-oa.conf"
],
"notes": "用户于 2026-07-17 批准 P0 设计。实现、只读复核、完整本地门禁和隔离 PostgreSQL 并发验证已通过;临时库与容器已删除。未授权也未执行公网配置、部署、现有/生产数据写入或生产迁移;当前仅待用户确认独立提交。",
"meta": {}
}