diff --git a/.trellis/spec/hyb-oa/backend-fastify-prisma.md b/.trellis/spec/hyb-oa/backend-fastify-prisma.md index 7afdb8ac..85301744 100644 --- a/.trellis/spec/hyb-oa/backend-fastify-prisma.md +++ b/.trellis/spec/hyb-oa/backend-fastify-prisma.md @@ -294,3 +294,92 @@ await buildApp(); // CJS plugin may still construct the real client ```js await buildApp({ prismaPlugin: testPrismaPlugin, startJobs: noOpStartJobs }); ``` + +## Scenario: Authentication Challenge Serialization + +### 1. Scope / Trigger + +- Trigger: changing password reset, email-code login, refresh rotation, + password login session creation, user password change or administrator reset. +- Applies when a credential event can consume a challenge, change + `token_version`, create/revoke a refresh session or invalidate another + challenge. + +### 2. Signatures + +- Row barrier: `SELECT ... FROM employees WHERE ... FOR UPDATE`. +- Challenge rows: `password_reset_codes` and `email_login_codes`. +- Session rows: `auth_sessions`. +- Security epoch: `employees.token_version`. + +### 3. Contracts + +- Use one lock order for credential mutations: Employee first, then challenge + and session rows. Refresh rotation must lock Employee and re-read the session + inside the same transaction before rotating it. +- Capture expiry, cooldown and lockout time after the Employee lock is acquired; + a request queued behind another credential event must not use stale time. +- Creating a new challenge invalidates the previous unused challenge while the + Employee lock is held. An active challenge with a wrong code increments the + account-level failure state; no active challenge returns the generic failure + without penalizing concurrent correct-request losers. +- Successful reset, user password change and administrator reset increment + `token_version`, revoke active refresh sessions and invalidate unused + challenges in the same transaction as the password change and audit event. +- Pepper-backed hashes use distinct purpose domains for email login and + password reset. Production rejects a missing, placeholder, weak or + JWT-reused pepper. + +### 4. Validation & Error Matrix + +- Unknown/inactive/locked account or missing/expired/consumed challenge -> + stable generic response; no account enumeration detail. +- Wrong code for the current active challenge -> increment account and + challenge attempts; reaching the bound locks and invalidates the challenge. +- Reset commits before login/refresh -> the queued request rechecks state and + cannot create a usable session. +- Login/refresh commits before reset -> reset observes and revokes the newly + created session before it commits. +- Concurrent consumers of one valid challenge -> exactly one successful state + transition; losers do not create audits, sessions or a recovery lock. + +### 5. Good/Base/Bad Cases + +- Good: every credential path uses Employee -> challenge/session ordering and + is covered by a deterministic PostgreSQL lock race. +- Base: sequential invalid-code attempts use the same account-level counters + and return the same external error contract. +- Bad: refresh locks a session before Employee while reset locks Employee + before sessions; this can leave a newly inserted session outside reset's + statement snapshot. +- Bad: treat `!challenge` after another request consumed the code as a new + failed guess and lock the legitimate account. + +### 6. Tests Required + +- Focused route tests for CSPRNG/HMAC domain separation, cooldown, account + lockout, missing-challenge losers, conditional consumption and session + revocation. +- Real isolated PostgreSQL checks for concurrent challenge requests, 20-way + same-code consumption, reset-first/login-first ordering and refresh/reset. +- After reset, assert the pre-reset access token and refresh cookie both fail, + the active-session count is zero and only one success audit exists. +- Prisma validate plus clean migration deployment from an empty database. + +### 7. Wrong vs Correct + +#### Wrong + +```js +await tx.authSession.updateMany({ where: { id: sessionId, revoked_at: null } }); +await tx.authSession.create({ data: replacement }); +``` + +#### Correct + +```js +await lockEmployeeForUpdate(tx, employeeId); +const currentSession = await tx.authSession.findUnique({ where: { id: sessionId } }); +if (!currentSession || currentSession.revoked_at) return invalidRefresh(); +await rotateSession(tx, currentSession); +``` diff --git a/.trellis/spec/hyb-oa/deployment-trialrun.md b/.trellis/spec/hyb-oa/deployment-trialrun.md index 25208e06..f786aac3 100644 --- a/.trellis/spec/hyb-oa/deployment-trialrun.md +++ b/.trellis/spec/hyb-oa/deployment-trialrun.md @@ -352,3 +352,93 @@ node deploy/trialrun/check-source-provenance.mjs BUILD_SHA= VITE_BUILD_SHA= \ docker compose -f deploy/trialrun/docker-compose.yml build server ``` + +## Scenario: Isolated Authentication Security Gate + +### 1. Scope / Trigger + +- Trigger: changing authentication challenges, credential/session transactions, + trusted proxy handling or the deployment order for their schema migration. +- The gate proves local PostgreSQL behavior only; public deployment and edge + changes remain separately approved operations. + +### 2. Signatures + +- Complete local gate: `bash scripts/qa/run-web-gates.sh auth-security-local`. +- Database allowlist: loopback PostgreSQL and a fresh `hyb_oa_auth_*` database + whose exact name is confirmed to the checker. +- Runtime guard: `RUN_AUTH_SECURITY_REGRESSION=1`; concurrency mode accepts only + `0` or `1` and reports `FULL` only for `1`. +- Public release/rollback gate: four challenge endpoints marked + `EXTERNAL_GATED` in the runbook and checklist. + +### 3. Contracts + +- Validate loopback API, loopback PostgreSQL, isolated database name, explicit + confirmation and test mode before the first HTTP write. +- The authentication gate generates per-run JWT, pepper and field-encryption + test keys; it must not inherit production values. Email, IM, WeCom and OIDC + side effects are disabled or injected with a no-network test service. +- PID/log paths are unique per run. Cleanup may stop only the server spawned by + the current run, and a database drop failure makes the gate fail. +- The runner removes only a database that this invocation successfully created; + failed creation must never authorize deletion of an existing database. +- Deployment or rollback to old challenge code requires an operator to close + `/api/auth/forgot-password`, `/api/auth/reset-password`, + `/api/auth/email-login/code` and `/api/auth/email-login/verify`. Scripts do + not claim this external action occurred. + +### 4. Validation & Error Matrix + +- Remote API/DB, wrong database prefix/confirmation or production mode -> fail + before login, seed-user mutation or challenge requests. +- Code-display logging, enabled external adapter or inherited proxy hop -> fail + the test-server guard. +- Concurrency value other than `0|1` -> fail; `0` -> `BASE_ONLY/PARTIAL`; `1` + plus all PostgreSQL scenarios -> `FULL`. +- Owned server cleanup failure or temporary database drop failure -> non-zero + gate result. +- Four-endpoint edge confirmation absent -> public release/rollback remains + `EXTERNAL_GATED`, regardless of local test success. + +### 5. Good/Base/Bad Cases + +- Good: disposable loopback PostgreSQL, synthetic secrets, no-network auth + email seam, five concurrency scenarios, zero residual databases and an + automatically removed container. +- Base: the HTTP regression runs without PostgreSQL concurrency and reports + partial evidence rather than a complete security pass. +- Bad: run several mutating HTTP steps and only then discover that the target + database or API is remote. +- Bad: reuse a shared PID file or ignore `dropdb` failure while returning exit + code zero. + +### 6. Tests Required + +- Static guard tests for remote authority, query-host override, exact database + confirmation, secret isolation, external-adapter disablement, owned cleanup + and redacted failure output. +- `bash -n` for the runner and regression scripts; Node syntax checks for the + checker and test-server entry. +- Prisma validate, clean migration deployment, the full isolated gate and an + explicit post-run query proving no `hyb_oa_auth_*` database remains. +- `node deploy/trialrun/validate-env-contract.js` must enforce loopback API + binding, XFF overwrite templates and four-endpoint `EXTERNAL_GATED` wording. + +### 7. Wrong vs Correct + +#### Wrong + +```bash +run_mutating_auth_regression +validate_target_afterwards +dropdb --if-exists "$DB" || true +``` + +#### Correct + +```bash +validate_isolated_target_before_writes +run_mutating_auth_regression +drop_owned_database_or_fail +``` diff --git a/.trellis/tasks/07-17-auth-challenge-trusted-proxy-hardening/check.jsonl b/.trellis/tasks/07-17-auth-challenge-trusted-proxy-hardening/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/07-17-auth-challenge-trusted-proxy-hardening/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/07-17-auth-challenge-trusted-proxy-hardening/design.md b/.trellis/tasks/07-17-auth-challenge-trusted-proxy-hardening/design.md new file mode 100644 index 00000000..9cff3f5f --- /dev/null +++ b/.trellis/tasks/07-17-auth-challenge-trusted-proxy-hardening/design.md @@ -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 和回滚配置。 diff --git a/.trellis/tasks/07-17-auth-challenge-trusted-proxy-hardening/implement.jsonl b/.trellis/tasks/07-17-auth-challenge-trusted-proxy-hardening/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/07-17-auth-challenge-trusted-proxy-hardening/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/07-17-auth-challenge-trusted-proxy-hardening/prd.md b/.trellis/tasks/07-17-auth-challenge-trusted-proxy-hardening/prd.md new file mode 100644 index 00000000..a9881bde --- /dev/null +++ b/.trellis/tasks/07-17-auth-challenge-trusted-proxy-hardening/prd.md @@ -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 无剩余产品偏好问题。 diff --git a/.trellis/tasks/07-17-auth-challenge-trusted-proxy-hardening/task.json b/.trellis/tasks/07-17-auth-challenge-trusted-proxy-hardening/task.json new file mode 100644 index 00000000..f5d4e631 --- /dev/null +++ b/.trellis/tasks/07-17-auth-challenge-trusted-proxy-hardening/task.json @@ -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": {} +} diff --git a/deploy/trialrun/.env.example b/deploy/trialrun/.env.example index dcec09b1..91a131d2 100644 --- a/deploy/trialrun/.env.example +++ b/deploy/trialrun/.env.example @@ -23,6 +23,7 @@ APP_ORIGIN=https://oa.example.com APP_TIMEZONE=Asia/Shanghai HOST=0.0.0.0 PORT=3000 +TRUST_PROXY_HOPS=1 COOKIE_SECURE=true OPENAPI_DOCS_ENABLED=false SERVE_CLIENT_DIST=true @@ -32,13 +33,22 @@ MAX_UPLOAD_SIZE_BYTES=10485760 # ===== 服务端密钥(必须修改) ===== JWT_SECRET= +AUTH_CODE_PEPPER= FIELD_ENCRYPTION_KEY= # ===== 密码 / 登录安全 ===== PASSWORD_RESET_CODE_TTL_SECONDS=600 +PASSWORD_RESET_FAIL_WINDOW_SECONDS=1800 +PASSWORD_RESET_MAX_ATTEMPTS=5 +PASSWORD_RESET_LOCK_SECONDS=1800 +PASSWORD_RESET_REQUEST_WINDOW_SECONDS=3600 +PASSWORD_RESET_REQUEST_MAX=3 +AUTH_CODE_REQUEST_COOLDOWN_SECONDS=60 DEV_SHOW_PASSWORD_RESET_CODE=false EMAIL_LOGIN_CODE_TTL_SECONDS=600 EMAIL_LOGIN_CODE_MAX_ATTEMPTS=5 +EMAIL_LOGIN_CODE_FAIL_WINDOW_SECONDS=1800 +EMAIL_LOGIN_CODE_LOCK_SECONDS=1800 AUTH_LOGIN_FAIL_WINDOW_SECONDS=900 AUTH_LOGIN_FAIL_MAX_ATTEMPTS=5 AUTH_LOGIN_LOCK_SECONDS=1800 diff --git a/deploy/trialrun/checklist.md b/deploy/trialrun/checklist.md index 2fca3cc4..c02d31fa 100644 --- a/deploy/trialrun/checklist.md +++ b/deploy/trialrun/checklist.md @@ -8,11 +8,13 @@ - [ ] 已在服务器创建 OA 目录(默认 `/opt/hyb-oa-trialrun`) - [ ] 已确认常规 SSH 用户为 `deploy`,系统配置用户为 `root`;当前不依赖 `shunli` 登录 - [ ] 已准备 `deploy/trialrun/.env`,并将 `APP_ORIGIN` 改为 OA 公网域名 +- [ ] 已配置独立、至少 32 字节的 `AUTH_CODE_PEPPER`,且不是仓库示例占位符 +- [ ] 已将 `TRUST_PROXY_HOPS` 固定为 `1` - [ ] 已将 `COOKIE_SECURE` 设为 `true` - [ ] 已确认 `deploy/trialrun/.env` 作为部署事实源,新增环境变量优先补到该文件 - [ ] 已尽量使用无引号 env 值;历史带引号值当前兼容,但建议逐步清理 - [ ] 已将 OA `Caddyfile` 站点块配置到 `/etc/caddy/Caddyfile` -- [ ] 已确认 `oa.hyinda.com { reverse_proxy 127.0.0.1:3000 }` 生效 +- [ ] 已确认 Caddy 反代到 `127.0.0.1:3000`,并用 `header_up X-Forwarded-For {remote_host}` 覆盖客户端 XFF - [ ] 已执行 `systemctl reload caddy` - [ ] 已显式提供 `DEPLOY_SERVER`、`REMOTE_DIR`,并从本地执行 `bash scripts/qa/run-release-gate.sh all` 或至少执行 `bash deploy/trialrun/deploy.sh` - [ ] 已通过 `https:///ready` 或首页登录完成最小探活 @@ -36,6 +38,7 @@ ## B. 发布(Deploy) - [ ] 本地工作树 clean,目标 commit 已进入 upstream;部署日志中的 commit 与 `/ready.data.buildSha` 一致 +- [ ] **EXTERNAL_GATED**:操作人已在边缘暂停 `/api/auth/forgot-password`、`/api/auth/reset-password`、`/api/auth/email-login/code`、`/api/auth/email-login/verify`;部署脚本不会自动修改边缘配置,未人工确认四端点关闭不得发布,配置预检、迁移、新应用和 smoke 全部通过后才人工恢复 - [ ] 标准路径已成功刷新 upstream;若因受控离线恢复使用 unpublished override,日志包含批准原因码和 `UNPUBLISHED-OVERRIDE` 标识 - [ ] 标准发布通过 `bash deploy/trialrun/deploy.sh` 执行;脚本自动注入完整后端 SHA 与 12 位前端 SHA,并完成构建、init 与 smoke - [ ] 若为首次初始化或受控应急而直接使用 Compose,已显式设置 `BUILD_SHA=` 与 `VITE_BUILD_SHA=`;未使用 `unknown` @@ -66,6 +69,7 @@ ## D. 回滚(Rollback) > 原则:如果涉及迁移,最快回滚通常是 “恢复备份 + 回到上个 tag”。 +- [ ] **EXTERNAL_GATED**:若回滚目标仍包含旧认证挑战逻辑,人工确认边缘继续关闭 `/api/auth/forgot-password`、`/api/auth/reset-password`、`/api/auth/email-login/code`、`/api/auth/email-login/verify`;脚本不会自动改边缘,未确认不得回滚开放,直到安全版本恢复 - [ ] 停止 server:`docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env stop server` - [ ] 恢复 DB:`./scripts/restore-db.sh ` - [ ] 恢复存储:`./scripts/restore-storage.sh deploy/trialrun/data/storage` diff --git a/deploy/trialrun/deploy.sh b/deploy/trialrun/deploy.sh index ac8e674d..bde8813a 100755 --- a/deploy/trialrun/deploy.sh +++ b/deploy/trialrun/deploy.sh @@ -172,25 +172,27 @@ echo ">>> 4.2 启动基础服务" if [[ "$ENABLE_MINIO" == "1" ]]; then "${compose_cmd[@]}" "${profile_args[@]}" up -d minio minio_init fi + +echo ">>> 4.3 构建最新 init 镜像" +"${compose_cmd[@]}" build init + +echo ">>> 4.4 预检新配置并执行 init" +"${compose_cmd[@]}" run --rm -T --entrypoint sh init -lc 'cd /app/server && node -e '\''require("./src/config").getConfig()'\'' && npm run prisma:migrate:deploy && npm run seed:config && npm run seed:trialrun' >> 4.5 构建并切换应用服务" "${compose_cmd[@]}" "${profile_args[@]}" up -d --build server if [[ "$ENABLE_WORKER" == "1" ]]; then "${compose_cmd[@]}" "${profile_args[@]}" up -d worker fi -echo ">>> 4.3 构建最新 init 镜像" -"${compose_cmd[@]}" build init - -echo ">>> 4.4 执行 init" -"${compose_cmd[@]}" run --rm -T --entrypoint sh init -lc 'cd /app/server && npm run prisma:migrate:deploy && npm run seed:config && npm run seed:trialrun' >> 4.5 校验 Prisma migration status" +echo ">>> 4.6 校验 Prisma migration status" if ! "${compose_cmd[@]}" exec -T server sh -lc 'cd /app/server && npx prisma migrate status' &2 "${compose_cmd[@]}" logs --tail=200 server || true exit 1 fi -echo ">>> 4.6 等待 /ready 并核对构建标识" +echo ">>> 4.7 等待 /ready 并核对构建标识" ready_ok=0 for i in $(seq 1 60); do if ready_payload="$(curl -fsS http://127.0.0.1:3000/ready 2>/dev/null)"; then @@ -211,21 +213,21 @@ if [[ "$ready_ok" != "1" ]]; then exit 1 fi -echo ">>> 4.7 执行 external deps smoke" +echo ">>> 4.8 执行 external deps smoke" if ! "${compose_cmd[@]}" exec -T server node src/scripts/smoke-external-deps.js &2 "${compose_cmd[@]}" logs --tail=200 server || true exit 1 fi -echo ">>> 4.8 执行 runtime smoke" +echo ">>> 4.9 执行 runtime smoke" if ! "${compose_cmd[@]}" exec -T server node src/scripts/smoke-trialrun-runtime.js &2 "${compose_cmd[@]}" logs --tail=200 server || true exit 1 fi -echo ">>> 4.9 当前服务状态" +echo ">>> 4.10 当前服务状态" "${compose_cmd[@]}" ps REMOTE diff --git a/deploy/trialrun/docker-compose.yml b/deploy/trialrun/docker-compose.yml index e9388180..bf459d2d 100644 --- a/deploy/trialrun/docker-compose.yml +++ b/deploy/trialrun/docker-compose.yml @@ -69,7 +69,7 @@ services: NODE_ENV: production BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit} ports: - - "3000:3000" + - "127.0.0.1:3000:3000" volumes: - ./data/storage:${STORAGE_DIR} diff --git a/deploy/trialrun/nginx-hyb-oa.conf b/deploy/trialrun/nginx-hyb-oa.conf index 47526391..553e90c4 100644 --- a/deploy/trialrun/nginx-hyb-oa.conf +++ b/deploy/trialrun/nginx-hyb-oa.conf @@ -39,7 +39,7 @@ server { proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port 443; @@ -53,7 +53,7 @@ server { proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port 443; diff --git a/deploy/trialrun/nginx.conf.example b/deploy/trialrun/nginx.conf.example index 7120d858..8b533b25 100644 --- a/deploy/trialrun/nginx.conf.example +++ b/deploy/trialrun/nginx.conf.example @@ -12,7 +12,7 @@ server { proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port; diff --git a/deploy/trialrun/runbook.md b/deploy/trialrun/runbook.md index 48373025..0e89fa33 100644 --- a/deploy/trialrun/runbook.md +++ b/deploy/trialrun/runbook.md @@ -7,6 +7,7 @@ - OA 新增独立二级域名,例如 `oa.hyinda.com`。 - OA 部署目录由受控环境中的 `REMOTE_DIR` 指定,不在仓库固化机器路径。 - OA 推荐访问方式:`Caddy(443) -> 127.0.0.1:3000 -> docker-compose(server)`。 +- Caddy 必须覆盖客户端传入的 `X-Forwarded-For`。OA 站点的 `reverse_proxy` 中应设置 `header_up X-Forwarded-For {remote_host}`,不得保留不可信客户端链。 - 首次初始化时: 1. 服务器以 root 执行 `bash deploy/trialrun/setup-server.sh` 2. 手工创建 `deploy/trialrun/.env` @@ -36,6 +37,8 @@ cp deploy/trialrun/.env.example deploy/trialrun/.env 必须修改: - `JWT_SECRET` +- `AUTH_CODE_PEPPER`(独立随机秘密,至少 32 字节,不得沿用示例占位符或 JWT secret) +- `TRUST_PROXY_HOPS=1`(当前单跳 Caddy/Nginx 部署固定为 1) - `APP_ORIGIN`(建议与对外域名一致,例如 `https://oa.example.com`) 环境变量约束: @@ -44,6 +47,7 @@ cp deploy/trialrun/.env.example deploy/trialrun/.env - 推荐使用无引号值,例如 `COOKIE_SECURE=true`、`PORT=3000`。 - 历史上已经写成带引号的 `.env` 当前仍兼容,但建议后续逐步改成无引号。 - 正式部署前可先执行 `node deploy/trialrun/validate-env-contract.js` 做静态契约检查。 +- **EXTERNAL_GATED**:涉及认证安全迁移时,发布或回滚前必须由操作人先在边缘暂停 `/api/auth/forgot-password`、`/api/auth/reset-password`、`/api/auth/email-login/code`、`/api/auth/email-login/verify` 四个 challenge 端点,再执行“新镜像配置预检 -> additive migration -> 新应用切换”。部署脚本不会自动修改边缘配置;未人工确认四端点已关闭不得发布或回滚开放。仅在安全版本及 smoke 全部通过后人工恢复;若回滚到旧认证代码,四端点必须继续关闭。 作业系统建议: - 试运行默认 `JOB_DRIVER=pgboss`(基于 Postgres 的队列调度,单机/多进程语义更稳) @@ -61,6 +65,8 @@ node deploy/trialrun/validate-env-contract.js export BUILD_SHA="" export VITE_BUILD_SHA="" docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d db +docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env build init +docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server ``` @@ -88,6 +94,7 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/ 说明: - `init` 是一次性任务:`migrate:deploy + seed:config + seed:trialrun`。 +- 初始化必须发生在依赖新增列的新 server 切换之前;正式部署脚本会先用新 init 镜像校验生产配置并执行迁移,再替换 server。 - 若 `TRIALRUN_ADMIN_PASSWORD` 为空,seed 会生成一次性口令并输出到控制台(请立刻保存)。 - 正式启用前,推荐先保持“空基线 + 默认种子”,不要直接导入开发库、测试库或旧 OA 整库备份。 - 如确需导入,仅导入最小必要主数据:部门、员工、角色、在用资产、活跃项目、合同元数据。 diff --git a/deploy/trialrun/validate-env-contract.js b/deploy/trialrun/validate-env-contract.js index 006172dc..083678d3 100755 --- a/deploy/trialrun/validate-env-contract.js +++ b/deploy/trialrun/validate-env-contract.js @@ -6,6 +6,12 @@ const rootDir = path.resolve(__dirname, '..', '..'); const configPath = path.join(rootDir, 'server/src/config.js'); const envExamplePath = path.join(rootDir, 'deploy/trialrun/.env.example'); const composePath = path.join(rootDir, 'deploy/trialrun/docker-compose.yml'); +const runbookPath = path.join(rootDir, 'deploy/trialrun/runbook.md'); +const checklistPath = path.join(rootDir, 'deploy/trialrun/checklist.md'); +const nginxPaths = [ + path.join(rootDir, 'deploy/trialrun/nginx-hyb-oa.conf'), + path.join(rootDir, 'deploy/trialrun/nginx.conf.example'), +]; const requiredServices = ['server', 'worker', 'init']; const deploymentInjectedEnvKeys = new Set(['BUILD_SHA']); @@ -122,6 +128,12 @@ function fail(message, errors) { const configContent = readFile(configPath); const envExampleContent = readFile(envExamplePath); const composeContent = readFile(composePath); +const runbookContent = readFile(runbookPath); +const checklistContent = readFile(checklistPath); +const nginxContents = nginxPaths.map((filePath) => ({ + filePath, + content: readFile(filePath), +})); const configKeys = extractConfigEnvKeys(configContent); const envExampleKeys = extractEnvExampleKeys(envExampleContent); @@ -172,6 +184,51 @@ for (const serviceName of requiredServices) { } } +const serverBlock = extractServiceBlock(composeContent, 'server'); +if (!serverBlock || !/^\s{6}-\s*["']127\.0\.0\.1:3000:3000["']\s*$/m.test(serverBlock)) { + errors.push('服务 server 的 3000 端口必须只绑定宿主回环地址'); +} + +for (const { filePath, content } of nginxContents) { + const relativePath = path.relative(rootDir, filePath); + if (content.includes('$proxy_add_x_forwarded_for')) { + errors.push(`${relativePath} 不得追加客户端提供的 X-Forwarded-For`); + } + + const forwardedForDirectives = [ + ...content.matchAll(/proxy_set_header\s+X-Forwarded-For\s+([^;]+);/g), + ]; + if ( + forwardedForDirectives.length === 0 || + forwardedForDirectives.some((match) => match[1].trim() !== '$remote_addr') + ) { + errors.push(`${relativePath} 必须用 $remote_addr 覆盖 X-Forwarded-For`); + } +} + +const authChallengeEndpoints = [ + '/api/auth/forgot-password', + '/api/auth/reset-password', + '/api/auth/email-login/code', + '/api/auth/email-login/verify', +]; +for (const [name, content] of [ + ['runbook.md', runbookContent], + ['checklist.md', checklistContent], +]) { + if (!content.includes('EXTERNAL_GATED')) { + errors.push(`${name} 必须标记认证 challenge 边缘操作为 EXTERNAL_GATED`); + } + for (const endpoint of authChallengeEndpoints) { + if (!content.includes(endpoint)) { + errors.push(`${name} 缺少认证 challenge 边缘关闭端点 ${endpoint}`); + } + } + if (!/脚本不会自动(?:修改|改)边缘/.test(content)) { + errors.push(`${name} 必须声明部署脚本不会自动修改边缘配置`); + } +} + if (errors.length) { fail('部署环境契约校验失败', errors); } diff --git a/scripts/qa/run-web-gates.sh b/scripts/qa/run-web-gates.sh index 2c005683..694ce7fc 100755 --- a/scripts/qa/run-web-gates.sh +++ b/scripts/qa/run-web-gates.sh @@ -17,15 +17,25 @@ QA_DB_ADMIN_DB="${QA_DB_ADMIN_DB:-postgres}" QA_DB_CLIENT="${QA_DB_CLIENT:-host}" QA_DB_DOCKER_CONTAINER="${QA_DB_DOCKER_CONTAINER:-hyb-oa-trialrun-db}" QA_DOCKER_USE_SG="${QA_DOCKER_USE_SG:-0}" -QA_DB_PREFIX="${QA_DB_PREFIX:-hyb_oa_webgate}" +if [[ "$MODE" == "auth-security-local" ]]; then + DEFAULT_QA_DB_PREFIX="hyb_oa_auth" +else + DEFAULT_QA_DB_PREFIX="hyb_oa_webgate" +fi +QA_DB_PREFIX="${QA_DB_PREFIX:-$DEFAULT_QA_DB_PREFIX}" QA_KEEP_DB="${QA_KEEP_DB:-0}" -QA_SERVER_LOG="${QA_SERVER_LOG:-/tmp/hyb-oa-web-gates-server.log}" -QA_SERVER_PID_FILE="${QA_SERVER_PID_FILE:-/tmp/hyb-oa-web-gates-server.pid}" +QA_RUN_ID="$(date +%Y%m%d%H%M%S)_$$_$RANDOM" +QA_SERVER_LOG="${QA_SERVER_LOG:-/tmp/hyb-oa-web-gates-server-${QA_RUN_ID}.log}" +QA_SERVER_PID_FILE="${QA_SERVER_PID_FILE:-/tmp/hyb-oa-web-gates-server-${QA_RUN_ID}.pid}" +QA_SERVER_ENTRY="src/index.js" QA_READY_TIMEOUT_SECONDS="${QA_READY_TIMEOUT_SECONDS:-90}" QA_BASE_URL="" QA_DB_NAME="${QA_DB_NAME:-${QA_DB_PREFIX}_$(date +%Y%m%d%H%M%S)_$RANDOM}" +QA_DB_CREATED=0 +QA_SERVER_STARTED=0 +QA_SERVER_OWNED=0 DATABASE_URL="postgresql://${QA_DB_USER}:${QA_DB_PASSWORD}@${QA_DB_HOST}:${QA_DB_PORT}/${QA_DB_NAME}?schema=public" -TEST_FIELD_ENCRYPTION_KEY_DEFAULT="${FIELD_ENCRYPTION_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=}" +TEST_FIELD_ENCRYPTION_KEY_DEFAULT="MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" qa_require_cmd() { command -v "$1" >/dev/null 2>&1 || { @@ -36,7 +46,7 @@ qa_require_cmd() { qa_usage() { cat < +Usage: bash scripts/qa/run-web-gates.sh 可选环境变量: QA_PORT / QA_HOST @@ -213,17 +223,32 @@ qa_dropdb() { return 0 fi - qa_psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${QA_DB_NAME}' AND pid <> pg_backend_pid();" >/dev/null || true + if ! qa_psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${QA_DB_NAME}' AND pid <> pg_backend_pid();" >/dev/null; then + echo "[ERROR] 临时数据库连接清理失败" >&2 + return 1 + fi if [[ "$QA_DB_CLIENT" == "docker" ]]; then - qa_docker exec -e "PGPASSWORD=$QA_DB_PASSWORD" "$QA_DB_DOCKER_CONTAINER" \ - dropdb --if-exists -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null || true + if ! qa_docker exec -e "PGPASSWORD=$QA_DB_PASSWORD" "$QA_DB_DOCKER_CONTAINER" \ + dropdb --if-exists -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null; then + echo "[ERROR] 临时数据库删除失败" >&2 + return 1 + fi + QA_DB_CREATED=0 return fi - PGPASSWORD="$QA_DB_PASSWORD" dropdb --if-exists -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null || true + if ! PGPASSWORD="$QA_DB_PASSWORD" dropdb --if-exists -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null; then + echo "[ERROR] 临时数据库删除失败" >&2 + return 1 + fi + QA_DB_CREATED=0 } qa_cleanup_server() { + if [[ "$QA_SERVER_STARTED" != "1" || "$QA_SERVER_OWNED" != "1" ]]; then + return 0 + fi + if [[ -f "$QA_SERVER_PID_FILE" ]]; then local pid pid="$(cat "$QA_SERVER_PID_FILE")" @@ -242,6 +267,8 @@ qa_cleanup_server() { fi rm -f "$QA_SERVER_PID_FILE" fi + QA_SERVER_STARTED=0 + QA_SERVER_OWNED=0 if qa_wait_port_free 15; then return 0 @@ -251,10 +278,23 @@ qa_cleanup_server() { } qa_cleanup() { - qa_cleanup_server - if [[ -n "${QA_DB_NAME:-}" && "$MODE" != "smoke-live" ]]; then - qa_dropdb + local original_status=$? + local cleanup_status=0 + trap - EXIT + + if ! qa_cleanup_server; then + cleanup_status=1 fi + if [[ "$QA_DB_CREATED" == "1" && "$MODE" != "smoke-live" ]]; then + if ! qa_dropdb; then + cleanup_status=1 + fi + fi + + if [[ "$original_status" != "0" ]]; then + exit "$original_status" + fi + exit "$cleanup_status" } qa_export_app_env() { @@ -267,6 +307,7 @@ qa_export_app_env() { export BASE_URL="$QA_BASE_URL" export DATABASE_URL="$DATABASE_URL" export JWT_SECRET="${JWT_SECRET:-qa_web_gates_secret_key}" + export AUTH_CODE_PEPPER="${AUTH_CODE_PEPPER:-qa_auth_code_pepper_with_at_least_32_bytes}" export OCR_PROVIDER="${OCR_PROVIDER:-mock}" export OCR_MAX_ATTEMPTS="${OCR_MAX_ATTEMPTS:-3}" export OCR_POLL_CRON="${OCR_POLL_CRON:-*/5 * * * * *}" @@ -290,7 +331,64 @@ qa_export_app_env() { export ERP_SYNC_ENABLED="${ERP_SYNC_ENABLED:-true}" export PAYMENT_BATCH_ENABLED="${PAYMENT_BATCH_ENABLED:-true}" export IM_NOTIFIER_ENABLED="true" - export FIELD_ENCRYPTION_KEY="$TEST_FIELD_ENCRYPTION_KEY_DEFAULT" + export FIELD_ENCRYPTION_KEY="${FIELD_ENCRYPTION_KEY:-$TEST_FIELD_ENCRYPTION_KEY_DEFAULT}" +} + +qa_run_auth_security_local() { + case "$QA_HOST" in + 127.0.0.1|localhost|::1) ;; + *) + echo "[ERROR] auth-security-local 只允许 loopback API" >&2 + exit 1 + ;; + esac + case "$QA_DB_HOST" in + 127.0.0.1|localhost|::1) ;; + *) + echo "[ERROR] auth-security-local 只允许 loopback PostgreSQL" >&2 + exit 1 + ;; + esac + if [[ ! "$QA_DB_NAME" =~ ^hyb_oa_auth_[A-Za-z0-9_]+$ ]]; then + echo "[ERROR] auth-security-local 数据库名必须使用 hyb_oa_auth_ 前缀" >&2 + exit 1 + fi + + qa_select_port + export JOBS_ENABLED=false + export RATE_LIMIT_ENABLED=false + export DEV_SHOW_PASSWORD_RESET_CODE=false + unset JWT_SECRET AUTH_CODE_PEPPER FIELD_ENCRYPTION_KEY + qa_export_app_env + export EMAIL_ENABLED=false + export SMTP_HOST="" + export SMTP_PORT="" + export SMTP_SECURE=false + export SMTP_USER="" + export SMTP_PASS="" + export IM_NOTIFIER_ENABLED=false + export WECOM_ENABLED=false + export WECOM_AGENT_SECRET="" + export OIDC_ENABLED=false + export OIDC_CLIENT_SECRET="" + unset JWT_SECRET AUTH_CODE_PEPPER FIELD_ENCRYPTION_KEY + JWT_SECRET="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))')" + AUTH_CODE_PEPPER="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))')" + FIELD_ENCRYPTION_KEY="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("base64"))')" + export JWT_SECRET AUTH_CODE_PEPPER FIELD_ENCRYPTION_KEY + export TRUST_PROXY_HOPS=0 + export SERVE_CLIENT_DIST=false + export RUN_AUTH_SECURITY_REGRESSION=1 + export AUTH_SECURITY_DATABASE_CONFIRM="$QA_DB_NAME" + QA_SERVER_ENTRY="src/scripts/start-auth-security-test-server.js" + qa_prepare_db + qa_start_server + + echo ">>> 执行认证安全与真实 PostgreSQL 并发回归" + ( + cd "$SERVER_DIR" + RUN_AUTH_POSTGRES_CONCURRENCY=1 ./scripts/regression-auth-security.sh + ) } qa_prepare_db() { @@ -303,6 +401,7 @@ qa_prepare_db() { echo ">>> 创建临时数据库: $QA_DB_NAME" qa_createdb + QA_DB_CREATED=1 echo ">>> 执行 Prisma migrate" ( @@ -318,6 +417,10 @@ qa_prepare_db() { } qa_start_server() { + if [[ "$QA_SERVER_STARTED" == "1" || "$QA_SERVER_OWNED" == "1" ]]; then + echo "[ERROR] 本轮 server 已启动,拒绝重复 spawn" >&2 + exit 1 + fi if ! qa_wait_port_free 15; then echo "[ERROR] 端口 ${QA_PORT} 已被占用,请改用 QA_PORT= 后重试。" >&2 exit 1 @@ -328,9 +431,15 @@ qa_start_server() { echo ">>> 启动本地 server: $QA_BASE_URL" ( cd "$SERVER_DIR" - nohup node src/index.js > "$QA_SERVER_LOG" 2>&1 & + nohup node "$QA_SERVER_ENTRY" > "$QA_SERVER_LOG" 2>&1 & echo $! > "$QA_SERVER_PID_FILE" ) + if [[ ! -s "$QA_SERVER_PID_FILE" ]]; then + echo "[ERROR] 未取得本轮 server PID" >&2 + exit 1 + fi + QA_SERVER_STARTED=1 + QA_SERVER_OWNED=1 qa_wait_ready "$QA_READY_TIMEOUT_SECONDS" || { echo "[ERROR] server 未就绪,最近日志如下:" >&2 @@ -607,6 +716,15 @@ case "$MODE" in trap qa_cleanup EXIT qa_run_full_local ;; + auth-security-local) + qa_require_cmd curl + qa_require_cmd jq + qa_require_db_client + qa_require_cmd node + qa_require_cmd npm + trap qa_cleanup EXIT + qa_run_auth_security_local + ;; business-flow-local) qa_require_cmd curl qa_require_db_client diff --git a/server/.env.example b/server/.env.example index 8e07b091..49534f86 100644 --- a/server/.env.example +++ b/server/.env.example @@ -5,13 +5,26 @@ DATABASE_URL="" # 仅需主动轮换已有开发账号时开启;轮换后账号必须首次登录改密。 # SEED_DEV_RESET_PASSWORDS="0" JWT_SECRET="" +AUTH_CODE_PEPPER="" # ===== 敏感字段加密(F25)===== # 用于员工银行卡/身份证等字段的可逆加密(AES-256-GCM)。 # 生成方式示例:openssl rand -base64 32 # FIELD_ENCRYPTION_KEY="" # APP_ORIGIN="https://oa.hyinda.com" # COOKIE_SECURE="true" -# DEV_SHOW_PASSWORD_RESET_CODE="true" +# TRUST_PROXY_HOPS="0" # 本机开发默认不信任 X-Forwarded-For;受控单跳反代使用 1 +# PASSWORD_RESET_CODE_TTL_SECONDS="600" +# PASSWORD_RESET_FAIL_WINDOW_SECONDS="1800" +# PASSWORD_RESET_MAX_ATTEMPTS="5" +# PASSWORD_RESET_LOCK_SECONDS="1800" +# PASSWORD_RESET_REQUEST_WINDOW_SECONDS="3600" +# PASSWORD_RESET_REQUEST_MAX="3" +# AUTH_CODE_REQUEST_COOLDOWN_SECONDS="60" +# EMAIL_LOGIN_CODE_TTL_SECONDS="600" +# EMAIL_LOGIN_CODE_MAX_ATTEMPTS="5" +# EMAIL_LOGIN_CODE_FAIL_WINDOW_SECONDS="1800" +# EMAIL_LOGIN_CODE_LOCK_SECONDS="1800" +# DEV_SHOW_PASSWORD_RESET_CODE="false" APP_TIMEZONE="Asia/Shanghai" STORAGE_DIR="generated/storage" # STORAGE_DRIVER="local" # local|s3(MinIO/S3 兼容) diff --git a/server/prisma/migrations/20260717120000_auth_challenge_trusted_proxy_hardening/migration.sql b/server/prisma/migrations/20260717120000_auth_challenge_trusted_proxy_hardening/migration.sql new file mode 100644 index 00000000..9b206e33 --- /dev/null +++ b/server/prisma/migrations/20260717120000_auth_challenge_trusted_proxy_hardening/migration.sql @@ -0,0 +1,28 @@ +ALTER TABLE "employees" + ADD COLUMN "email_login_code_failed_count" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN "email_login_code_failed_first_at" TIMESTAMP(3), + ADD COLUMN "email_login_code_locked_until" TIMESTAMP(3), + ADD COLUMN "email_login_code_last_requested_at" TIMESTAMP(3), + ADD COLUMN "password_reset_failed_count" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN "password_reset_failed_first_at" TIMESTAMP(3), + ADD COLUMN "password_reset_locked_until" TIMESTAMP(3), + ADD COLUMN "password_reset_request_count" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN "password_reset_request_first_at" TIMESTAMP(3), + ADD COLUMN "password_reset_last_requested_at" TIMESTAMP(3); + +ALTER TABLE "password_reset_codes" + ADD COLUMN "invalidated_at" TIMESTAMP(3), + ADD COLUMN "attempts_count" INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX "password_reset_codes_employee_id_invalidated_at_created_at_idx" + ON "password_reset_codes"("employee_id", "invalidated_at", "created_at"); + +-- Existing codes were generated and consumed under the weaker legacy contract. +-- Invalidate them at rollout instead of carrying unsafe compatibility forward. +UPDATE "password_reset_codes" +SET "invalidated_at" = CURRENT_TIMESTAMP +WHERE "used_at" IS NULL AND "invalidated_at" IS NULL; + +UPDATE "email_login_codes" +SET "used_at" = CURRENT_TIMESTAMP +WHERE "used_at" IS NULL; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index c4f973c1..b010124a 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -605,6 +605,16 @@ model Employee { login_failed_count Int @default(0) login_failed_first_at DateTime? login_locked_until DateTime? + email_login_code_failed_count Int @default(0) + email_login_code_failed_first_at DateTime? + email_login_code_locked_until DateTime? + email_login_code_last_requested_at DateTime? + password_reset_failed_count Int @default(0) + password_reset_failed_first_at DateTime? + password_reset_locked_until DateTime? + password_reset_request_count Int @default(0) + password_reset_request_first_at DateTime? + password_reset_last_requested_at DateTime? archived_at DateTime? created_at DateTime @default(now()) @@ -723,6 +733,8 @@ model PasswordResetCode { code_hash String expires_at DateTime used_at DateTime? + invalidated_at DateTime? + attempts_count Int @default(0) created_at DateTime @default(now()) created_ip String? created_user_agent String? @@ -730,6 +742,7 @@ model PasswordResetCode { employee Employee @relation(fields: [employee_id], references: [id], onDelete: Restrict) @@index([employee_id, created_at]) + @@index([employee_id, invalidated_at, created_at]) @@index([expires_at]) @@map("password_reset_codes") } diff --git a/server/scripts/regression-auth-security.sh b/server/scripts/regression-auth-security.sh index 20cbe005..21fd220d 100755 --- a/server/scripts/regression-auth-security.sh +++ b/server/scripts/regression-auth-security.sh @@ -7,6 +7,15 @@ ADMIN_IDENTIFIER="${ADMIN_IDENTIFIER:-admin@hyinda.com}" ADMIN_PASSWORD="${ADMIN_PASSWORD:-AdminPassword1234}" LOGIN_FAIL_MAX_ATTEMPTS="${LOGIN_FAIL_MAX_ATTEMPTS:-${AUTH_LOGIN_FAIL_MAX_ATTEMPTS:-5}}" +RUN_AUTH_POSTGRES_CONCURRENCY="${RUN_AUTH_POSTGRES_CONCURRENCY:-0}" + +case "$RUN_AUTH_POSTGRES_CONCURRENCY" in + 0|1) ;; + *) + echo "[AUTH-REGRESSION][FAIL] invalid_concurrency_mode" >&2 + exit 1 + ;; +esac if ! command -v curl >/dev/null 2>&1; then echo "[ERROR] curl not found" >&2 @@ -18,6 +27,23 @@ if ! command -v jq >/dev/null 2>&1; then exit 1 fi +if ! command -v node >/dev/null 2>&1; then + echo "[ERROR] node not found" >&2 + exit 1 +fi + +SERVER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if ! node -e ' + try { + require(process.argv[1]).validateAuthRegressionEnvironment(process.env); + } catch { + process.exit(1); + } +' "$SERVER_DIR/src/scripts/check-auth-postgres-concurrency.js"; then + echo "[AUTH-REGRESSION][FAIL] environment_guard_rejected" >&2 + exit 1 +fi + TMP_DIR="$(mktemp -d)" cleanup() { rm -rf "$TMP_DIR" @@ -34,12 +60,8 @@ assert_status() { local actual="$1" local expected="$2" local name="$3" - local body_file="${4:-}" if [[ "$actual" != "$expected" ]]; then echo "[ERROR] $name expected HTTP $expected but got $actual" >&2 - if [[ -n "$body_file" && -f "$body_file" ]]; then - cat "$body_file" >&2 - fi exit 1 fi } @@ -52,7 +74,6 @@ assert_error_code() { actual_code="$(jq -r '.error.code // empty' "$file_path")" if [[ "$actual_code" != "$expected_code" ]]; then echo "[ERROR] $name expected error code $expected_code but got $actual_code" >&2 - cat "$file_path" >&2 exit 1 fi } @@ -80,7 +101,7 @@ ADMIN_TOKEN="$(extract_json_field "$admin_login_body" '.data.accessToken')" echo "[2/8] Weak password should be rejected" weak_employee_no="W$(date +%s)$RANDOM" -weak_employee_email="weak_${weak_employee_no}@hyinda.com" +weak_employee_email="weak_${weak_employee_no,,}@hyinda.com" weak_employee_phone="139${RANDOM}${RANDOM}" weak_create_body="$TMP_DIR/weak-create.json" weak_create_status="$(curl -sS -o "$weak_create_body" -w "%{http_code}" \ @@ -91,14 +112,13 @@ weak_create_status="$(curl -sS -o "$weak_create_body" -w "%{http_code}" \ assert_status "$weak_create_status" "400" "weak password rejected" "$weak_create_body" assert_error_code "$weak_create_body" "VALID_001" "weak password rejected" if ! jq -e '.error.details.password | type == "array" and (map(tostring) | join(" ") | contains("常见"))' "$weak_create_body" >/dev/null; then - echo "[ERROR] weak password error message missing" >&2 - cat "$weak_create_body" >&2 + echo "[ERROR] weak password validation category missing" >&2 exit 1 fi echo "[3/8] Create employee (initial password => mustChangePassword)" employee_no="A$(date +%s)$RANDOM" -employee_email="auth_${employee_no}@hyinda.com" +employee_email="auth_${employee_no,,}@hyinda.com" employee_phone="138${RANDOM}${RANDOM}" INITIAL_PASSWORD="InitA1$(date +%s%N)Xy" create_body="$TMP_DIR/create.json" @@ -115,7 +135,6 @@ login_status_1="$(login "$employee_email" "$INITIAL_PASSWORD" "${DEVICE_FINGERPR assert_status "$login_status_1" "200" "initial login" "$login_body_1" if [[ "$(jq -r '.data.mustChangePassword' "$login_body_1")" != "true" ]]; then echo "[ERROR] expected mustChangePassword=true on initial login" >&2 - cat "$login_body_1" >&2 exit 1 fi ACCESS_TOKEN_1="$(extract_json_field "$login_body_1" '.data.accessToken')" @@ -133,7 +152,6 @@ assert_status "$change_status" "200" "change password" "$change_body" ACCESS_TOKEN_2="$(extract_json_field "$change_body" '.data.accessToken')" if [[ "$(jq -r '.data.mustChangePassword' "$change_body")" != "false" ]]; then echo "[ERROR] expected mustChangePassword=false after change" >&2 - cat "$change_body" >&2 exit 1 fi @@ -165,7 +183,6 @@ login_status_2="$(login "$employee_email" "$ONE_TIME_PASSWORD" "${DEVICE_FINGERP assert_status "$login_status_2" "200" "login with reset password" "$login_body_2" if [[ "$(jq -r '.data.mustChangePassword' "$login_body_2")" != "true" ]]; then echo "[ERROR] expected mustChangePassword=true after admin reset" >&2 - cat "$login_body_2" >&2 exit 1 fi @@ -183,7 +200,6 @@ for i in $(seq 1 "$LOGIN_FAIL_MAX_ATTEMPTS"); do assert_error_code "$attempt_body" "AUTH_002" "lock attempt $i triggers lock" if [[ "$(jq -r '.error.details.lockedUntil // empty' "$attempt_body")" == "" ]]; then echo "[ERROR] expected lockedUntil in AUTH_002 details" >&2 - cat "$attempt_body" >&2 exit 1 fi locked=1 @@ -211,5 +227,19 @@ post_unlock_login_body="$TMP_DIR/post-unlock-login.json" post_unlock_login_status="$(login "$employee_email" "$ONE_TIME_PASSWORD" "${DEVICE_FINGERPRINT}-post-unlock" "$post_unlock_login_body")" assert_status "$post_unlock_login_status" "200" "login after unlock" "$post_unlock_login_body" -echo "[8/8] Done" -echo "[OK] Auth security regression passed" +if [[ "$RUN_AUTH_POSTGRES_CONCURRENCY" == "1" ]]; then + echo "[8/9] PostgreSQL auth challenge concurrency" + AUTH_SECURITY_EMPLOYEE_ID="$EMPLOYEE_ID" \ + AUTH_SECURITY_EMPLOYEE_EMAIL="$employee_email" \ + AUTH_SECURITY_CURRENT_PASSWORD="$ONE_TIME_PASSWORD" \ + node src/scripts/check-auth-postgres-concurrency.js +else + echo "[8/9] PostgreSQL auth challenge concurrency [SKIP: explicit opt-in required]" +fi + +echo "[9/9] Done" +if [[ "$RUN_AUTH_POSTGRES_CONCURRENCY" == "1" ]]; then + echo "[AUTH-REGRESSION][PASS] FULL" +else + echo "[AUTH-REGRESSION][PARTIAL] BASE_ONLY" +fi diff --git a/server/src/app.js b/server/src/app.js index 816d3789..6f8b0a3e 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -124,7 +124,7 @@ async function buildApp(dependencies = {}) { censor: "[REDACTED]", }, }, - trustProxy: true, + trustProxy: config.trustProxy, genReqId: () => crypto.randomUUID(), }); @@ -336,7 +336,10 @@ async function buildApp(dependencies = {}) { app.log.info("Metrics endpoint enabled at /metrics"); } - await app.register(authRoutes, { prefix: "/api/auth" }); + await app.register(authRoutes, { + prefix: "/api/auth", + emailService: dependencies.authEmailService, + }); await app.register(wecomAuthRoutes, { prefix: "/api/auth" }); await app.register(wecomApprovalArchiveRoutes, { prefix: "/api" }); await app.register(wecomApprovalCallbackRoutes, { prefix: "/api" }); diff --git a/server/src/config.js b/server/src/config.js index 6ae162fa..6e70189f 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -16,11 +16,110 @@ function normalizeBuildSha(value) { return /^[0-9a-f]{7,64}$/.test(sha) ? sha.slice(0, 12) : "unknown"; } +function parseBoundedInteger(name, value, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) { + const normalized = typeof value === "number" ? String(value) : String(value ?? "").trim(); + if (!/^\d+$/.test(normalized)) { + throw new Error(`Invalid ${name}`); + } + + const parsed = Number(normalized); + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new Error(`Invalid ${name}`); + } + return parsed; +} + +function assertProductionAuthCodePepper(value) { + const isPlaceholder = /^<[^>]+>$/.test(value); + if (isPlaceholder || Buffer.byteLength(value, "utf8") < 32) { + throw new Error("AUTH_CODE_PEPPER must be a non-placeholder secret of at least 32 bytes"); + } +} + function getConfig() { const nodeEnv = process.env.NODE_ENV ?? "development"; const serverRoot = path.resolve(__dirname, ".."); const storageDir = process.env.STORAGE_DIR ?? "generated/storage"; const clientDistDir = process.env.CLIENT_DIST_DIR ?? "../client/dist"; + const jwtSecret = requiredEnv("JWT_SECRET"); + const devShowPasswordResetCode = process.env.DEV_SHOW_PASSWORD_RESET_CODE === "true"; + if (nodeEnv === "production" && devShowPasswordResetCode) { + throw new Error("DEV_SHOW_PASSWORD_RESET_CODE is forbidden in production"); + } + + const authCodePepper = String(process.env.AUTH_CODE_PEPPER ?? "").trim(); + if (nodeEnv === "production" && !authCodePepper) { + throw new Error("Missing required env: AUTH_CODE_PEPPER"); + } + if (nodeEnv === "production") { + assertProductionAuthCodePepper(authCodePepper); + if (authCodePepper === jwtSecret) { + throw new Error("AUTH_CODE_PEPPER must be independent from JWT_SECRET"); + } + } + + const trustProxyHopsValue = + process.env.TRUST_PROXY_HOPS ?? (nodeEnv === "production" ? requiredEnv("TRUST_PROXY_HOPS") : "0"); + const trustProxyHops = parseBoundedInteger("TRUST_PROXY_HOPS", trustProxyHopsValue, { + min: nodeEnv === "production" ? 1 : 0, + max: nodeEnv === "production" ? 1 : 8, + }); + + const passwordResetCodeTtlSeconds = parseBoundedInteger( + "PASSWORD_RESET_CODE_TTL_SECONDS", + process.env.PASSWORD_RESET_CODE_TTL_SECONDS ?? 10 * ONE_MINUTE_SECONDS, + { min: ONE_MINUTE_SECONDS, max: ONE_DAY_SECONDS }, + ); + const passwordResetFailWindowSeconds = parseBoundedInteger( + "PASSWORD_RESET_FAIL_WINDOW_SECONDS", + process.env.PASSWORD_RESET_FAIL_WINDOW_SECONDS ?? 30 * ONE_MINUTE_SECONDS, + { min: ONE_MINUTE_SECONDS, max: 7 * ONE_DAY_SECONDS }, + ); + const passwordResetMaxAttempts = parseBoundedInteger( + "PASSWORD_RESET_MAX_ATTEMPTS", + process.env.PASSWORD_RESET_MAX_ATTEMPTS ?? 5, + { min: 1, max: 20 }, + ); + const passwordResetLockSeconds = parseBoundedInteger( + "PASSWORD_RESET_LOCK_SECONDS", + process.env.PASSWORD_RESET_LOCK_SECONDS ?? 30 * ONE_MINUTE_SECONDS, + { min: ONE_MINUTE_SECONDS, max: 7 * ONE_DAY_SECONDS }, + ); + const passwordResetRequestWindowSeconds = parseBoundedInteger( + "PASSWORD_RESET_REQUEST_WINDOW_SECONDS", + process.env.PASSWORD_RESET_REQUEST_WINDOW_SECONDS ?? 60 * ONE_MINUTE_SECONDS, + { min: ONE_MINUTE_SECONDS, max: 7 * ONE_DAY_SECONDS }, + ); + const passwordResetRequestMax = parseBoundedInteger( + "PASSWORD_RESET_REQUEST_MAX", + process.env.PASSWORD_RESET_REQUEST_MAX ?? 3, + { min: 1, max: 20 }, + ); + const authCodeRequestCooldownSeconds = parseBoundedInteger( + "AUTH_CODE_REQUEST_COOLDOWN_SECONDS", + process.env.AUTH_CODE_REQUEST_COOLDOWN_SECONDS ?? ONE_MINUTE_SECONDS, + { min: 1, max: ONE_DAY_SECONDS }, + ); + const emailLoginCodeTtlSeconds = parseBoundedInteger( + "EMAIL_LOGIN_CODE_TTL_SECONDS", + process.env.EMAIL_LOGIN_CODE_TTL_SECONDS ?? 10 * ONE_MINUTE_SECONDS, + { min: ONE_MINUTE_SECONDS, max: ONE_DAY_SECONDS }, + ); + const emailLoginCodeMaxAttempts = parseBoundedInteger( + "EMAIL_LOGIN_CODE_MAX_ATTEMPTS", + process.env.EMAIL_LOGIN_CODE_MAX_ATTEMPTS ?? 5, + { min: 1, max: 20 }, + ); + const emailLoginCodeFailWindowSeconds = parseBoundedInteger( + "EMAIL_LOGIN_CODE_FAIL_WINDOW_SECONDS", + process.env.EMAIL_LOGIN_CODE_FAIL_WINDOW_SECONDS ?? 30 * ONE_MINUTE_SECONDS, + { min: ONE_MINUTE_SECONDS, max: 7 * ONE_DAY_SECONDS }, + ); + const emailLoginCodeLockSeconds = parseBoundedInteger( + "EMAIL_LOGIN_CODE_LOCK_SECONDS", + process.env.EMAIL_LOGIN_CODE_LOCK_SECONDS ?? 30 * ONE_MINUTE_SECONDS, + { min: ONE_MINUTE_SECONDS, max: 7 * ONE_DAY_SECONDS }, + ); return { nodeEnv, @@ -29,10 +128,12 @@ function getConfig() { buildSha: normalizeBuildSha(process.env.BUILD_SHA), metricsEnabled: process.env.METRICS_ENABLED !== "false", + trustProxy: trustProxyHops > 0 ? trustProxyHops : false, appOrigin: process.env.APP_ORIGIN ?? "", - jwtSecret: requiredEnv("JWT_SECRET"), + jwtSecret, fieldEncryptionKey: process.env.FIELD_ENCRYPTION_KEY ?? "", + authCodePepper: authCodePepper || jwtSecret, accessTokenTtl: "15m", refreshTtlSeconds: 7 * ONE_DAY_SECONDS, @@ -43,15 +144,19 @@ function getConfig() { ? process.env.COOKIE_SECURE === "true" : nodeEnv === "production", - passwordResetCodeTtlSeconds: Number( - process.env.PASSWORD_RESET_CODE_TTL_SECONDS ?? 10 * 60, - ), - emailLoginCodeTtlSeconds: Number( - process.env.EMAIL_LOGIN_CODE_TTL_SECONDS ?? 10 * 60, - ), - emailLoginCodeMaxAttempts: Number(process.env.EMAIL_LOGIN_CODE_MAX_ATTEMPTS ?? 5), + passwordResetCodeTtlSeconds, + passwordResetFailWindowSeconds, + passwordResetMaxAttempts, + passwordResetLockSeconds, + passwordResetRequestWindowSeconds, + passwordResetRequestMax, + authCodeRequestCooldownSeconds, + emailLoginCodeTtlSeconds, + emailLoginCodeMaxAttempts, + emailLoginCodeFailWindowSeconds, + emailLoginCodeLockSeconds, - devShowPasswordResetCode: process.env.DEV_SHOW_PASSWORD_RESET_CODE === "true", + devShowPasswordResetCode, loginFailWindowSeconds: Number( process.env.AUTH_LOGIN_FAIL_WINDOW_SECONDS ?? 15 * ONE_MINUTE_SECONDS, @@ -209,4 +314,4 @@ function getConfig() { }; } -module.exports = { getConfig }; +module.exports = { getConfig, parseBoundedInteger }; diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index b3ab2a03..61695d73 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -3,12 +3,20 @@ const crypto = require("node:crypto"); const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); -const { hashPassword, randomRefreshToken, sha256Hex, verifyPassword } = require("../utils/crypto"); +const { + generateSixDigitCode, + hashAuthCode, + hashPassword, + randomRefreshToken, + sha256Hex, + timingSafeEqualHex, + verifyPassword, +} = require("../utils/crypto"); const { validatePassword } = require("../utils/passwordPolicy"); const { signPayload, verifyToken } = require("../utils/signedUrl"); const { sendData } = require("../utils/reply"); const { createNotifications } = require("../services/notifications"); -const { resolveEmailDeliveryStatus, sendNotificationEmailSafe } = require("../services/email"); +const defaultEmailService = require("../services/email"); const { generators, getOidcClient } = require("../services/oidc"); const PENDING_WECOM_BIND_COOKIE_NAME = "wecom_login_bind"; @@ -228,12 +236,18 @@ function parseAccessTokenTtlSeconds(value) { return amount * secondsByUnit[unit]; } -function generateSixDigitCode() { - return String(Math.floor(Math.random() * 1_000_000)).padStart(6, "0"); -} +const AUTH_CODE_PURPOSE = Object.freeze({ + emailLogin: "email_login", + passwordReset: "password_reset", +}); -function hashEmailLoginCode(employeeId, code, secret) { - return crypto.createHmac("sha256", secret).update(`${employeeId}:${code}`).digest("hex"); +function hashEmployeeAuthCode(config, employeeId, code, purpose) { + return hashAuthCode({ + employeeId, + code, + purpose, + pepper: config.authCodePepper, + }); } function emailLoginCodeError() { @@ -245,6 +259,200 @@ function emailLoginCodeError() { }); } +function passwordResetCodeError() { + return new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { code: "invalid_or_expired" }, + }); +} + +function isFutureDate(value, now) { + return value instanceof Date && value.getTime() > now.getTime(); +} + +function isWithinWindow(value, now, windowMs) { + return value instanceof Date && now.getTime() - value.getTime() <= windowMs; +} + +function nextFailureState({ currentCount, firstAt, now, windowMs, maxAttempts, lockMs }) { + let nextFirstAt = firstAt instanceof Date ? firstAt : now; + const parsedCount = Number(currentCount ?? 0); + let nextCount = Number.isInteger(parsedCount) && parsedCount >= 0 ? parsedCount : 0; + + if (!isWithinWindow(nextFirstAt, now, windowMs)) { + nextFirstAt = now; + nextCount = 0; + } + + nextCount += 1; + const locked = nextCount >= maxAttempts; + return { + count: nextCount, + firstAt: nextFirstAt, + locked, + lockedUntil: locked ? new Date(now.getTime() + lockMs) : null, + }; +} + +function nextRequestState({ currentCount, firstAt, lastRequestedAt, now, cooldownMs, windowMs, maxRequests }) { + if (isWithinWindow(lastRequestedAt, now, cooldownMs)) { + return { allowed: false }; + } + + let nextFirstAt = firstAt instanceof Date ? firstAt : now; + const parsedCount = Number(currentCount ?? 0); + let nextCount = Number.isInteger(parsedCount) && parsedCount >= 0 ? parsedCount : 0; + if (!isWithinWindow(nextFirstAt, now, windowMs)) { + nextFirstAt = now; + nextCount = 0; + } + + if (nextCount >= maxRequests) { + return { allowed: false }; + } + + return { + allowed: true, + count: nextCount + 1, + firstAt: nextFirstAt, + }; +} + +async function lockAuthEmployeeByEmail(tx, email) { + const rows = await tx.$queryRaw` + SELECT + id, + status, + token_version, + must_change_password, + email_login_code_failed_count, + email_login_code_failed_first_at, + email_login_code_locked_until, + email_login_code_last_requested_at, + password_reset_failed_count, + password_reset_failed_first_at, + password_reset_locked_until, + password_reset_request_count, + password_reset_request_first_at, + password_reset_last_requested_at + FROM "employees" + WHERE LOWER(email) = ${email} + ORDER BY id + LIMIT 2 + FOR UPDATE + `; + return Array.isArray(rows) && rows.length === 1 ? rows[0] : null; +} + +async function lockAuthEmployeeById(tx, employeeId) { + const rows = await tx.$queryRaw` + SELECT + id, + status, + token_version + FROM "employees" + WHERE id = ${employeeId}::uuid + FOR UPDATE + `; + return Array.isArray(rows) && rows.length === 1 ? rows[0] : null; +} + +async function recordEmailLoginCodeFailure(tx, { employee, loginCode, request, config, now }) { + const failure = nextFailureState({ + currentCount: employee.email_login_code_failed_count, + firstAt: employee.email_login_code_failed_first_at, + now, + windowMs: config.emailLoginCodeFailWindowSeconds * 1000, + maxAttempts: config.emailLoginCodeMaxAttempts, + lockMs: config.emailLoginCodeLockSeconds * 1000, + }); + + await tx.employee.update({ + where: { id: employee.id }, + data: { + email_login_code_failed_count: failure.count, + email_login_code_failed_first_at: failure.firstAt, + email_login_code_locked_until: failure.lockedUntil, + }, + }); + + if (loginCode) { + await tx.emailLoginCode.updateMany({ + where: { id: loginCode.id, used_at: null }, + data: { + attempts_count: { increment: 1 }, + ...(failure.locked ? { used_at: now } : {}), + }, + }); + } + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: employee.id, + action: failure.locked ? "AUTH_EMAIL_LOGIN_LOCKED" : "AUTH_EMAIL_LOGIN_FAILED", + targetType: "employee", + targetId: employee.id, + extra: { + attemptsCount: failure.count, + locked: failure.locked, + }, + }), + }); + + return { ok: false }; +} + +async function recordPasswordResetFailure(tx, { employee, resetCode, request, config, now }) { + const failure = nextFailureState({ + currentCount: employee.password_reset_failed_count, + firstAt: employee.password_reset_failed_first_at, + now, + windowMs: config.passwordResetFailWindowSeconds * 1000, + maxAttempts: config.passwordResetMaxAttempts, + lockMs: config.passwordResetLockSeconds * 1000, + }); + + await tx.employee.update({ + where: { id: employee.id }, + data: { + password_reset_failed_count: failure.count, + password_reset_failed_first_at: failure.firstAt, + password_reset_locked_until: failure.lockedUntil, + }, + }); + + if (resetCode) { + await tx.passwordResetCode.updateMany({ + where: { + id: resetCode.id, + used_at: null, + invalidated_at: null, + }, + data: { + attempts_count: { increment: 1 }, + ...(failure.locked ? { invalidated_at: now } : {}), + }, + }); + } + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: employee.id, + action: failure.locked ? "AUTH_PASSWORD_RESET_LOCKED" : "AUTH_PASSWORD_RESET_FAILED", + targetType: "employee", + targetId: employee.id, + extra: { + attemptsCount: failure.count, + locked: failure.locked, + }, + }), + }); + + return { ok: false }; +} + function emailDeliveryUnavailableError() { return new AppError({ code: "SYS_001", @@ -254,7 +462,7 @@ function emailDeliveryUnavailableError() { }); } -function assertEmailDeliveryReady(config) { +function assertEmailDeliveryReady(config, resolveEmailDeliveryStatus) { const status = resolveEmailDeliveryStatus(config); if (!status.ready) { throw emailDeliveryUnavailableError(); @@ -326,7 +534,9 @@ async function createRefreshSession(tx, { employeeId, refreshTokenHash, deviceFi }); } -async function authRoutes(fastify) { +async function authRoutes(fastify, options = {}) { + const emailService = options.emailService ?? defaultEmailService; + const { resolveEmailDeliveryStatus, sendNotificationEmailSafe } = emailService; const loginBodySchema = z.object({ identifier: z.string().min(3), password: z.string().min(1), @@ -521,14 +731,30 @@ async function authRoutes(fastify) { let wecomBindingStatus = null; await fastify.prisma.$transaction(async (tx) => { - await tx.employee.update({ - where: { id: employee.id }, + const guardedEmployee = await tx.employee.updateMany({ + where: { + id: employee.id, + status: "active", + password_hash: employee.password_hash, + token_version: employee.token_version, + OR: [ + { login_locked_until: null }, + { login_locked_until: { lte: now } }, + ], + }, data: { login_failed_count: 0, login_failed_first_at: null, login_locked_until: null, }, }); + if (guardedEmployee.count !== 1) { + throw new AppError({ + code: "AUTH_001", + message: "用户名或密码错误", + statusCode: 401, + }); + } await createRefreshSession(tx, { employeeId: employee.id, @@ -637,55 +863,84 @@ async function authRoutes(fastify) { }); } - assertEmailDeliveryReady(fastify.config); + assertEmailDeliveryReady(fastify.config, resolveEmailDeliveryStatus); const { email } = parsed.data; - const employee = await fastify.prisma.employee.findFirst({ - where: { email }, - select: { id: true, status: true }, + const issuedCode = await fastify.prisma.$transaction(async (tx) => { + const employee = await lockAuthEmployeeByEmail(tx, email); + const now = new Date(); + if ( + !employee || + employee.status !== "active" || + isFutureDate(employee.email_login_code_locked_until, now) || + isWithinWindow( + employee.email_login_code_last_requested_at, + now, + fastify.config.authCodeRequestCooldownSeconds * 1000, + ) + ) { + return null; + } + + const code = generateSixDigitCode(); + const codeHash = hashEmployeeAuthCode( + fastify.config, + employee.id, + code, + AUTH_CODE_PURPOSE.emailLogin, + ); + const expiresAt = new Date( + now.getTime() + fastify.config.emailLoginCodeTtlSeconds * 1000, + ); + + await tx.emailLoginCode.updateMany({ + where: { employee_id: employee.id, used_at: null }, + data: { used_at: now }, + }); + await tx.emailLoginCode.create({ + data: { + employee_id: employee.id, + code_hash: codeHash, + expires_at: expiresAt, + created_ip: request.ip, + created_user_agent: getHeaderString(request, "user-agent"), + }, + }); + await tx.employee.update({ + where: { id: employee.id }, + data: { email_login_code_last_requested_at: now }, + }); + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: employee.id, + action: "AUTH_EMAIL_LOGIN_CODE_REQUESTED", + targetType: "employee", + targetId: employee.id, + extra: {}, + }), + }); + + return { employeeId: employee.id, code, expiresAt }; }); - if (!employee || employee.status !== "active") { - return sendData(reply, request, {}); + if (issuedCode) { + const emailMessage = buildAuthVerificationEmail({ + code: issuedCode.code, + expiresAt: issuedCode.expiresAt, + purpose: "login", + }); + void sendNotificationEmailSafe({ + prisma: fastify.prisma, + config: fastify.config, + logger: fastify.log, + requestId: request.id, + type: "auth.email_login_code_requested", + recipientEmployeeIds: [issuedCode.employeeId], + subject: emailMessage.subject, + body: emailMessage.body, + }); } - const code = generateSixDigitCode(); - const codeHash = hashEmailLoginCode(employee.id, code, fastify.config.jwtSecret); - const now = new Date(); - const expiresAt = new Date(now.getTime() + fastify.config.emailLoginCodeTtlSeconds * 1000); - const emailMessage = buildAuthVerificationEmail({ code, expiresAt, purpose: "login" }); - - await fastify.prisma.emailLoginCode.create({ - data: { - employee_id: employee.id, - code_hash: codeHash, - expires_at: expiresAt, - created_ip: request.ip, - created_user_agent: getHeaderString(request, "user-agent"), - }, - }); - - await fastify.prisma.auditLog.create({ - data: buildAuditLogData(request, { - actorId: employee.id, - action: "AUTH_EMAIL_LOGIN_CODE_REQUESTED", - targetType: "employee", - targetId: employee.id, - extra: {}, - }), - }); - - void sendNotificationEmailSafe({ - prisma: fastify.prisma, - config: fastify.config, - logger: fastify.log, - requestId: request.id, - type: "auth.email_login_code_requested", - recipientEmployeeIds: [employee.id], - subject: emailMessage.subject, - body: emailMessage.body, - }); - return sendData(reply, request, {}); }); @@ -711,71 +966,67 @@ async function authRoutes(fastify) { } const { email, code } = parsed.data; - const employee = await fastify.prisma.employee.findFirst({ - where: { email }, - select: { - id: true, - status: true, - token_version: true, - must_change_password: true, - }, - }); - - if (!employee || employee.status !== "active") { - throw emailLoginCodeError(); - } - - const now = new Date(); - const loginCode = await fastify.prisma.emailLoginCode.findFirst({ - where: { - employee_id: employee.id, - used_at: null, - expires_at: { gt: now }, - }, - orderBy: { created_at: "desc" }, - select: { - id: true, - code_hash: true, - attempts_count: true, - }, - }); - - if (!loginCode || loginCode.attempts_count >= fastify.config.emailLoginCodeMaxAttempts) { - throw emailLoginCodeError(); - } - - const expectedCodeHash = hashEmailLoginCode(employee.id, code, fastify.config.jwtSecret); - if (loginCode.code_hash !== expectedCodeHash) { - const nextAttempts = loginCode.attempts_count + 1; - await fastify.prisma.$transaction(async (tx) => { - await tx.emailLoginCode.update({ - where: { id: loginCode.id }, - data: { attempts_count: { increment: 1 } }, - }); - await tx.auditLog.create({ - data: buildAuditLogData(request, { - actorId: employee.id, - action: "AUTH_EMAIL_LOGIN_FAILED", - targetType: "employee", - targetId: employee.id, - extra: { attemptsCount: nextAttempts }, - }), - }); - }); - throw emailLoginCodeError(); - } - const refreshToken = randomRefreshToken(); const refreshTokenHash = sha256Hex(refreshToken); - const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000); - await fastify.prisma.$transaction(async (tx) => { + const outcome = await fastify.prisma.$transaction(async (tx) => { + const employee = await lockAuthEmployeeByEmail(tx, email); + const now = new Date(); + if ( + !employee || + employee.status !== "active" || + isFutureDate(employee.email_login_code_locked_until, now) + ) { + return { ok: false }; + } + + const loginCode = await tx.emailLoginCode.findFirst({ + where: { + employee_id: employee.id, + used_at: null, + expires_at: { gt: now }, + }, + orderBy: { created_at: "desc" }, + select: { + id: true, + code_hash: true, + attempts_count: true, + }, + }); + const expectedCodeHash = hashEmployeeAuthCode( + fastify.config, + employee.id, + code, + AUTH_CODE_PURPOSE.emailLogin, + ); + if (!loginCode) { + return { ok: false }; + } + if ( + loginCode.attempts_count >= fastify.config.emailLoginCodeMaxAttempts || + !timingSafeEqualHex(loginCode.code_hash, expectedCodeHash) + ) { + return recordEmailLoginCodeFailure(tx, { + employee, + loginCode, + request, + config: fastify.config, + now, + }); + } + const used = await tx.emailLoginCode.updateMany({ - where: { id: loginCode.id, used_at: null }, + where: { + id: loginCode.id, + employee_id: employee.id, + code_hash: loginCode.code_hash, + used_at: null, + expires_at: { gt: now }, + }, data: { used_at: now }, }); if (used.count !== 1) { - throw emailLoginCodeError(); + return { ok: false }; } await tx.employee.update({ @@ -784,6 +1035,9 @@ async function authRoutes(fastify) { login_failed_count: 0, login_failed_first_at: null, login_locked_until: null, + email_login_code_failed_count: 0, + email_login_code_failed_first_at: null, + email_login_code_locked_until: null, }, }); @@ -793,7 +1047,7 @@ async function authRoutes(fastify) { deviceFingerprint, request, now, - expiresAt, + expiresAt: new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000), }); await tx.auditLog.create({ @@ -805,8 +1059,14 @@ async function authRoutes(fastify) { extra: {}, }), }); + + return { ok: true, employee }; }); + if (!outcome.ok) { + throw emailLoginCodeError(); + } + reply.setCookie( fastify.config.refreshCookieName, refreshToken, @@ -814,11 +1074,11 @@ async function authRoutes(fastify) { ); reply.setCookie("has_session", "1", sessionHintCookieOptions(fastify.config)); - const accessToken = fastify.issueAccessToken(employee); + const accessToken = fastify.issueAccessToken(outcome.employee); return sendData(reply, request, { accessToken, expiresIn: accessTokenExpiresIn, - mustChangePassword: Boolean(employee.must_change_password), + mustChangePassword: Boolean(outcome.employee.must_change_password), }); }); @@ -843,8 +1103,6 @@ async function authRoutes(fastify) { } const refreshTokenHash = sha256Hex(refreshToken); - const now = new Date(); - const session = await fastify.prisma.authSession.findFirst({ where: { refresh_token_hash: refreshTokenHash }, select: { @@ -888,79 +1146,72 @@ async function authRoutes(fastify) { }); } - if (session.expires_at <= now) { - throw new AppError({ - code: "AUTH_005", - message: "Refresh Token 无效/已撤销", - statusCode: 401, - }); - } + const newRefreshToken = randomRefreshToken(); + const newRefreshTokenHash = sha256Hex(newRefreshToken); + const outcome = await fastify.prisma.$transaction(async (tx) => { + const employee = await lockAuthEmployeeById(tx, session.employee_id); + if (!employee || employee.status !== "active") { + return { ok: false }; + } - if (session.revoked_at) { - const rotatedReplacement = await fastify.prisma.authSession.findFirst({ - where: { - employee_id: session.employee_id, - device_fingerprint: session.device_fingerprint, - revoked_at: null, - expires_at: { gt: now }, - created_at: { gt: session.revoked_at }, + const now = new Date(); + const currentSession = await tx.authSession.findUnique({ + where: { id: session.id }, + select: { + id: true, + employee_id: true, + device_fingerprint: true, + expires_at: true, + revoked_at: true, }, - select: { id: true }, }); + if ( + !currentSession || + currentSession.employee_id !== employee.id || + currentSession.device_fingerprint !== deviceFingerprint || + currentSession.expires_at <= now + ) { + return { ok: false }; + } - if (rotatedReplacement) { - await fastify.prisma.$transaction(async (tx) => { - await tx.authSession.updateMany({ - where: { employee_id: session.employee_id, revoked_at: null }, - data: { revoked_at: now }, - }); + if (currentSession.revoked_at) { + const rotatedReplacement = await tx.authSession.findFirst({ + where: { + employee_id: employee.id, + device_fingerprint: currentSession.device_fingerprint, + revoked_at: null, + expires_at: { gt: now }, + created_at: { gt: currentSession.revoked_at }, + }, + select: { id: true }, + }); + if (rotatedReplacement) { await tx.employee.update({ - where: { id: session.employee_id }, + where: { id: employee.id }, data: { token_version: { increment: 1 } }, }); - + await tx.authSession.updateMany({ + where: { employee_id: employee.id, revoked_at: null }, + data: { revoked_at: now }, + }); await tx.auditLog.create({ data: buildAuditLogData(request, { - actorId: session.employee_id, + actorId: employee.id, action: "REFRESH_REUSE_DETECTED", targetType: "employee", - targetId: session.employee_id, + targetId: employee.id, extra: {}, }), }); - }); + } + + return { ok: false }; } - reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config)); - reply.clearCookie("has_session", sessionHintCookieOptions(fastify.config)); - throw new AppError({ - code: "AUTH_005", - message: "Refresh Token 无效/已撤销", - statusCode: 401, - }); - } - - const employee = await fastify.prisma.employee.findUnique({ - where: { id: session.employee_id }, - select: { id: true, status: true, token_version: true }, - }); - if (!employee || employee.status !== "active") { - throw new AppError({ - code: "AUTH_005", - message: "Refresh Token 无效/已撤销", - statusCode: 401, - }); - } - - const newRefreshToken = randomRefreshToken(); - const newRefreshTokenHash = sha256Hex(newRefreshToken); - const newExpiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000); - - await fastify.prisma.$transaction(async (tx) => { const updated = await tx.authSession.updateMany({ where: { - id: session.id, + id: currentSession.id, revoked_at: null, expires_at: { gt: now }, }, @@ -974,14 +1225,9 @@ async function authRoutes(fastify) { targetType: "employee", targetId: employee.id, extra: {}, - }), - }); - - throw new AppError({ - code: "AUTH_005", - message: "Refresh Token 无效/已撤销", - statusCode: 401, - }); + }), + }); + return { ok: false }; } await tx.authSession.create({ @@ -991,11 +1237,23 @@ async function authRoutes(fastify) { device_fingerprint: deviceFingerprint, ip_address: request.ip, user_agent: getHeaderString(request, "user-agent") ?? "", - expires_at: newExpiresAt, + expires_at: new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000), }, }); + + return { ok: true, employee }; }); + if (!outcome.ok) { + reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config)); + reply.clearCookie("has_session", sessionHintCookieOptions(fastify.config)); + throw new AppError({ + code: "AUTH_005", + message: "Refresh Token 无效/已撤销", + statusCode: 401, + }); + } + reply.setCookie( fastify.config.refreshCookieName, newRefreshToken, @@ -1003,7 +1261,7 @@ async function authRoutes(fastify) { ); reply.setCookie("has_session", "1", sessionHintCookieOptions(fastify.config)); - const accessToken = fastify.issueAccessToken(employee); + const accessToken = fastify.issueAccessToken(outcome.employee); return sendData(reply, request, { accessToken, expiresIn: accessTokenExpiresIn, @@ -1360,26 +1618,51 @@ async function authRoutes(fastify) { const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000); const updatedEmployee = await fastify.prisma.$transaction(async (tx) => { - const updated = await tx.employee.update({ - where: { id: employee.id }, + const guardedEmployee = await tx.employee.updateMany({ + where: { + id: employee.id, + status: "active", + password_hash: employee.password_hash, + token_version: employee.token_version, + }, data: { password_hash: newPasswordHash, must_change_password: false, login_failed_count: 0, login_failed_first_at: null, login_locked_until: null, + email_login_code_failed_count: 0, + email_login_code_failed_first_at: null, + email_login_code_locked_until: null, + password_reset_failed_count: 0, + password_reset_failed_first_at: null, + password_reset_locked_until: null, + password_reset_request_count: 0, + password_reset_request_first_at: null, + password_reset_last_requested_at: null, token_version: { increment: 1 }, }, - select: { - id: true, - token_version: true, - }, }); + if (guardedEmployee.count !== 1) { + throw new AppError({ + code: "AUTH_005", + message: "会话已失效,请重新登录", + statusCode: 401, + }); + } await tx.authSession.updateMany({ where: { employee_id: employee.id, revoked_at: null }, data: { revoked_at: now, last_seen_at: now }, }); + await tx.passwordResetCode.updateMany({ + where: { employee_id: employee.id, invalidated_at: null }, + data: { invalidated_at: now }, + }); + await tx.emailLoginCode.updateMany({ + where: { employee_id: employee.id, used_at: null }, + data: { used_at: now }, + }); await tx.authSession.create({ data: { @@ -1402,7 +1685,10 @@ async function authRoutes(fastify) { }), }); - return updated; + return { + id: employee.id, + token_version: employee.token_version + 1, + }; }); reply.setCookie( @@ -1435,76 +1721,125 @@ async function authRoutes(fastify) { }); } - assertEmailDeliveryReady(fastify.config); + assertEmailDeliveryReady(fastify.config, resolveEmailDeliveryStatus); const { email } = parsed.data; - const employee = await fastify.prisma.employee.findFirst({ - where: { email }, - select: { id: true, status: true }, - }); + const issuedCode = await fastify.prisma.$transaction(async (tx) => { + const employee = await lockAuthEmployeeByEmail(tx, email); + const now = new Date(); + if ( + !employee || + employee.status !== "active" || + isFutureDate(employee.password_reset_locked_until, now) + ) { + return null; + } - if (!employee || employee.status !== "active") { - return sendData(reply, request, {}); - } - - const code = String(Math.floor(Math.random() * 1_000_000)).padStart(6, "0"); - const codeHash = sha256Hex(code); - const now = new Date(); - const expiresAt = new Date(now.getTime() + fastify.config.passwordResetCodeTtlSeconds * 1000); - const emailMessage = buildAuthVerificationEmail({ code, expiresAt, purpose: "password_reset" }); - - await fastify.prisma.passwordResetCode.create({ - data: { - employee_id: employee.id, - code_hash: codeHash, - expires_at: expiresAt, - created_ip: request.ip, - created_user_agent: getHeaderString(request, "user-agent"), - }, - }); - - await fastify.prisma.auditLog.create({ - data: buildAuditLogData(request, { - actorId: employee.id, - action: "AUTH_PASSWORD_RESET_REQUESTED", - targetType: "employee", - targetId: employee.id, - extra: {}, - }), - }); - - try { - await createNotifications(fastify.prisma, { - recipientEmployeeIds: [employee.id], - type: "auth.password_reset_requested", - title: "【安全】收到密码重置请求", - body: "如非本人操作,请尽快联系管理员核查账号安全。", - targetType: "employee", - targetId: employee.id, + const requestState = nextRequestState({ + currentCount: employee.password_reset_request_count, + firstAt: employee.password_reset_request_first_at, + lastRequestedAt: employee.password_reset_last_requested_at, + now, + cooldownMs: fastify.config.authCodeRequestCooldownSeconds * 1000, + windowMs: fastify.config.passwordResetRequestWindowSeconds * 1000, + maxRequests: fastify.config.passwordResetRequestMax, }); - } catch (error) { - fastify.log.error( - { err: error, employeeId: employee.id, requestId: request.id }, - "Failed to create password reset requested notifications", - ); - } + if (!requestState.allowed) { + return null; + } - void sendNotificationEmailSafe({ - prisma: fastify.prisma, - config: fastify.config, - logger: fastify.log, - requestId: request.id, - type: "auth.password_reset_requested", - recipientEmployeeIds: [employee.id], - subject: emailMessage.subject, - body: emailMessage.body, + const code = generateSixDigitCode(); + const codeHash = hashEmployeeAuthCode( + fastify.config, + employee.id, + code, + AUTH_CODE_PURPOSE.passwordReset, + ); + const expiresAt = new Date( + now.getTime() + fastify.config.passwordResetCodeTtlSeconds * 1000, + ); + + await tx.passwordResetCode.updateMany({ + where: { + employee_id: employee.id, + used_at: null, + invalidated_at: null, + }, + data: { invalidated_at: now }, + }); + await tx.passwordResetCode.create({ + data: { + employee_id: employee.id, + code_hash: codeHash, + expires_at: expiresAt, + created_ip: request.ip, + created_user_agent: getHeaderString(request, "user-agent"), + }, + }); + await tx.employee.update({ + where: { id: employee.id }, + data: { + password_reset_request_count: requestState.count, + password_reset_request_first_at: requestState.firstAt, + password_reset_last_requested_at: now, + }, + }); + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: employee.id, + action: "AUTH_PASSWORD_RESET_REQUESTED", + targetType: "employee", + targetId: employee.id, + extra: {}, + }), + }); + + return { employeeId: employee.id, code, expiresAt }; }); - if (fastify.config.devShowPasswordResetCode) { - fastify.log.info( - { employeeId: employee.id, email, code, requestId: request.id }, - "DEV password reset code", - ); + if (issuedCode) { + const emailMessage = buildAuthVerificationEmail({ + code: issuedCode.code, + expiresAt: issuedCode.expiresAt, + purpose: "password_reset", + }); + try { + await createNotifications(fastify.prisma, { + recipientEmployeeIds: [issuedCode.employeeId], + type: "auth.password_reset_requested", + title: "【安全】收到密码重置请求", + body: "如非本人操作,请尽快联系管理员核查账号安全。", + targetType: "employee", + targetId: issuedCode.employeeId, + }); + } catch { + fastify.log.error( + { + errorCode: "notification_create_failed", + employeeId: issuedCode.employeeId, + requestId: request.id, + }, + "Failed to create password reset requested notifications", + ); + } + + void sendNotificationEmailSafe({ + prisma: fastify.prisma, + config: fastify.config, + logger: fastify.log, + requestId: request.id, + type: "auth.password_reset_requested", + recipientEmployeeIds: [issuedCode.employeeId], + subject: emailMessage.subject, + body: emailMessage.body, + }); + + if (fastify.config.devShowPasswordResetCode) { + fastify.log.info( + { employeeId: issuedCode.employeeId, code: issuedCode.code, requestId: request.id }, + "DEV password reset code", + ); + } } return sendData(reply, request, {}); @@ -1512,7 +1847,7 @@ async function authRoutes(fastify) { const resetPasswordBodySchema = z.object({ email: z.string().trim().email().transform((value) => value.toLowerCase()), - code: z.string().length(6), + code: z.string().regex(/^\d{6}$/), newPassword: z.string().min(12), }); @@ -1539,44 +1874,68 @@ async function authRoutes(fastify) { }); } - const employee = await fastify.prisma.employee.findFirst({ - where: { email }, - select: { id: true, status: true }, - }); - if (!employee || employee.status !== "active") { - return sendData(reply, request, {}); - } + const outcome = await fastify.prisma.$transaction(async (tx) => { + const employee = await lockAuthEmployeeByEmail(tx, email); + const now = new Date(); + if ( + !employee || + employee.status !== "active" || + isFutureDate(employee.password_reset_locked_until, now) + ) { + return { ok: false }; + } - const now = new Date(); - const codeHash = sha256Hex(code); - const resetCode = await fastify.prisma.passwordResetCode.findFirst({ - where: { - employee_id: employee.id, - code_hash: codeHash, - used_at: null, - expires_at: { gt: now }, - }, - orderBy: { created_at: "desc" }, - select: { id: true }, - }); - if (!resetCode) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { code: "invalid_or_expired" }, + const resetCode = await tx.passwordResetCode.findFirst({ + where: { + employee_id: employee.id, + used_at: null, + invalidated_at: null, + expires_at: { gt: now }, + }, + orderBy: { created_at: "desc" }, + select: { + id: true, + code_hash: true, + attempts_count: true, + }, }); - } + const expectedCodeHash = hashEmployeeAuthCode( + fastify.config, + employee.id, + code, + AUTH_CODE_PURPOSE.passwordReset, + ); + if (!resetCode) { + return { ok: false }; + } + if ( + resetCode.attempts_count >= fastify.config.passwordResetMaxAttempts || + !timingSafeEqualHex(resetCode.code_hash, expectedCodeHash) + ) { + return recordPasswordResetFailure(tx, { + employee, + resetCode, + request, + config: fastify.config, + now, + }); + } - const newPasswordHash = await hashPassword(newPassword); - - const emailTasks = []; - - await fastify.prisma.$transaction(async (tx) => { - await tx.passwordResetCode.update({ - where: { id: resetCode.id }, + const newPasswordHash = await hashPassword(newPassword); + const consumed = await tx.passwordResetCode.updateMany({ + where: { + id: resetCode.id, + employee_id: employee.id, + code_hash: resetCode.code_hash, + used_at: null, + invalidated_at: null, + expires_at: { gt: now }, + }, data: { used_at: now }, }); + if (consumed.count !== 1) { + return { ok: false }; + } await tx.employee.update({ where: { id: employee.id }, @@ -1586,13 +1945,30 @@ async function authRoutes(fastify) { login_failed_count: 0, login_failed_first_at: null, login_locked_until: null, + email_login_code_failed_count: 0, + email_login_code_failed_first_at: null, + email_login_code_locked_until: null, + password_reset_failed_count: 0, + password_reset_failed_first_at: null, + password_reset_locked_until: null, + password_reset_request_count: 0, + password_reset_request_first_at: null, + password_reset_last_requested_at: null, token_version: { increment: 1 }, }, }); await tx.authSession.updateMany({ where: { employee_id: employee.id, revoked_at: null }, - data: { revoked_at: now }, + data: { revoked_at: now, last_seen_at: now }, + }); + await tx.passwordResetCode.updateMany({ + where: { employee_id: employee.id, invalidated_at: null }, + data: { invalidated_at: now }, + }); + await tx.emailLoginCode.updateMany({ + where: { employee_id: employee.id, used_at: null }, + data: { used_at: now }, }); await tx.auditLog.create({ @@ -1605,40 +1981,44 @@ async function authRoutes(fastify) { }), }); - try { - await createNotifications(tx, { - recipientEmployeeIds: [employee.id], - type: "auth.password_reset_succeeded", - title: "【安全】密码已重置", - body: "你的账号密码已重置成功。如非本人操作,请立即联系管理员。", - targetType: "employee", - targetId: employee.id, - }); - - emailTasks.push({ - type: "auth.password_reset_succeeded", - recipientEmployeeIds: [employee.id], - subject: "【安全】密码已重置", - body: "你的账号密码已重置成功。如非本人操作,请立即联系管理员。", - }); - } catch (error) { - fastify.log.error( - { err: error, employeeId: employee.id, requestId: request.id }, - "Failed to create password reset succeeded notifications", - ); - } + return { ok: true, employeeId: employee.id }; }); - for (const task of emailTasks) { - void sendNotificationEmailSafe({ - prisma: fastify.prisma, - config: fastify.config, - logger: fastify.log, - requestId: request.id, - ...task, - }); + if (!outcome.ok) { + throw passwordResetCodeError(); } + try { + await createNotifications(fastify.prisma, { + recipientEmployeeIds: [outcome.employeeId], + type: "auth.password_reset_succeeded", + title: "【安全】密码已重置", + body: "你的账号密码已重置成功。如非本人操作,请立即联系管理员。", + targetType: "employee", + targetId: outcome.employeeId, + }); + } catch { + fastify.log.error( + { + errorCode: "notification_create_failed", + employeeId: outcome.employeeId, + requestId: request.id, + }, + "Failed to create password reset succeeded notifications", + ); + } + + void sendNotificationEmailSafe({ + prisma: fastify.prisma, + config: fastify.config, + logger: fastify.log, + requestId: request.id, + type: "auth.password_reset_succeeded", + recipientEmployeeIds: [outcome.employeeId], + subject: "【安全】密码已重置", + body: "你的账号密码已重置成功。如非本人操作,请立即联系管理员。", + }); + reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config)); reply.clearCookie("has_session", sessionHintCookieOptions(fastify.config)); return sendData(reply, request, {}); diff --git a/server/src/routes/users.js b/server/src/routes/users.js index 350f481e..dd3f2674 100644 --- a/server/src/routes/users.js +++ b/server/src/routes/users.js @@ -46,11 +46,18 @@ const listPaginationSchema = z.object({ pageSize: z.coerce.number().int().min(1).max(100).optional(), }); +const normalizedEmailSchema = z + .string() + .trim() + .email() + .max(200) + .transform((value) => value.toLowerCase()); + const createBodySchema = z.object({ employeeNo: z.string().trim().min(1).max(50).optional(), name: z.string().min(1).max(100), phone: z.string().min(6).max(30), - email: z.string().email().max(200), + email: normalizedEmailSchema, departmentId: z.string().uuid().optional(), title: z.string().min(1).max(100).optional(), directManagerId: z.string().uuid().nullable().optional(), @@ -63,7 +70,7 @@ const createBodySchema = z.object({ const updateBodySchema = z.object({ name: z.string().trim().min(1).max(100).optional(), phone: z.string().trim().min(6).max(30).optional(), - email: z.string().trim().email().max(200).optional(), + email: normalizedEmailSchema.optional(), departmentId: z.string().uuid().optional(), title: z.string().trim().min(1).max(100).optional(), directManagerId: z.string().uuid().nullable().optional(), @@ -544,7 +551,7 @@ function buildImportRowDraft(rawRow, defaultHireDate) { employeeNo: normalizeImportCell(rawRow.data.employeeNo), name: normalizeImportCell(rawRow.data.name), phone: normalizeImportCell(rawRow.data.phone), - email: normalizeImportCell(rawRow.data.email), + email: normalizeImportCell(rawRow.data.email).toLowerCase(), departmentName: normalizeImportCell(rawRow.data.departmentName), title: normalizeImportCell(rawRow.data.title) || "员工", directManagerEmployeeNo: normalizeImportCell(rawRow.data.directManagerEmployeeNo), @@ -934,7 +941,7 @@ async function createSystemUser(tx, options) { : {}), name: options.name, phone: options.phone, - email: options.email, + email: options.email.trim().toLowerCase(), department_id: options.departmentId, title: options.title ?? "员工", direct_manager_id: options.directManagerId ?? null, @@ -2137,6 +2144,15 @@ async function usersRoutes(fastify, opts = {}) { login_failed_count: 0, login_failed_first_at: null, login_locked_until: null, + email_login_code_failed_count: 0, + email_login_code_failed_first_at: null, + email_login_code_locked_until: null, + password_reset_failed_count: 0, + password_reset_failed_first_at: null, + password_reset_locked_until: null, + password_reset_request_count: 0, + password_reset_request_first_at: null, + password_reset_last_requested_at: null, token_version: { increment: 1 }, updated_by: request.employee.id, }, @@ -2144,7 +2160,15 @@ async function usersRoutes(fastify, opts = {}) { await tx.authSession.updateMany({ where: { employee_id: userId, revoked_at: null }, - data: { revoked_at: now }, + data: { revoked_at: now, last_seen_at: now }, + }); + await tx.passwordResetCode.updateMany({ + where: { employee_id: userId, invalidated_at: null }, + data: { invalidated_at: now }, + }); + await tx.emailLoginCode.updateMany({ + where: { employee_id: userId, used_at: null }, + data: { used_at: now }, }); await tx.auditLog.create({ diff --git a/server/src/scripts/check-auth-postgres-concurrency.js b/server/src/scripts/check-auth-postgres-concurrency.js new file mode 100644 index 00000000..a2e041d9 --- /dev/null +++ b/server/src/scripts/check-auth-postgres-concurrency.js @@ -0,0 +1,991 @@ +const crypto = require("node:crypto"); +const { Client } = require("pg"); + +const { createPrismaClient } = require("../prismaClient"); +const { + generateSixDigitCode, + hashAuthCode, + randomRefreshToken, + sha256Hex, + verifyPassword, +} = require("../utils/crypto"); + +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]); +const EMPLOYEE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +class AuthConcurrencyCheckError extends Error { + constructor(code) { + super(code); + this.name = "AuthConcurrencyCheckError"; + this.safeCode = code; + } +} + +function fail(code) { + throw new AuthConcurrencyCheckError(code); +} + +function requireValue(env, name) { + const value = String(env[name] ?? "").trim(); + if (!value) fail(`${name.toLowerCase()}_required`); + return value; +} + +function validateAuthRegressionEnvironment(env = process.env) { + if (env.RUN_AUTH_SECURITY_REGRESSION !== "1") fail("regression_opt_in_required"); + if (env.NODE_ENV !== "test") fail("test_environment_required"); + if (env.RATE_LIMIT_ENABLED !== "false") fail("rate_limit_must_be_disabled"); + if (env.DEV_SHOW_PASSWORD_RESET_CODE !== "false") fail("code_logging_forbidden"); + if (env.TRUST_PROXY_HOPS !== "0") fail("trust_proxy_must_be_disabled"); + if (env.EMAIL_ENABLED !== "false") fail("email_provider_must_be_disabled"); + if (String(env.SMTP_HOST ?? "").trim()) fail("smtp_host_forbidden"); + if (String(env.SMTP_USER ?? "").trim() || String(env.SMTP_PASS ?? "").trim()) { + fail("smtp_credentials_forbidden"); + } + if (env.IM_NOTIFIER_ENABLED !== "false") fail("im_notifier_must_be_disabled"); + if (env.WECOM_ENABLED !== "false") fail("wecom_must_be_disabled"); + if (String(env.WECOM_AGENT_SECRET ?? "").trim()) fail("wecom_secret_forbidden"); + if (env.OIDC_ENABLED !== "false") fail("oidc_must_be_disabled"); + if (String(env.OIDC_CLIENT_SECRET ?? "").trim()) fail("oidc_secret_forbidden"); + + const databaseUrl = requireValue(env, "DATABASE_URL"); + let parsedDatabaseUrl; + try { + parsedDatabaseUrl = new URL(databaseUrl); + } catch { + fail("database_url_invalid"); + } + if (!["postgres:", "postgresql:"].includes(parsedDatabaseUrl.protocol)) { + fail("database_protocol_invalid"); + } + + const pgConfig = new Client({ connectionString: databaseUrl }); + const databaseHost = String(pgConfig.host ?? "").toLowerCase(); + const databaseName = String(pgConfig.database ?? ""); + if (!LOOPBACK_HOSTS.has(databaseHost)) fail("database_host_not_loopback"); + if (!/^hyb_oa_auth_[A-Za-z0-9_]+$/.test(databaseName)) fail("database_name_not_isolated"); + if (requireValue(env, "AUTH_SECURITY_DATABASE_CONFIRM") !== databaseName) { + fail("database_confirmation_mismatch"); + } + + const baseUrl = requireValue(env, "BASE_URL"); + let parsedBaseUrl; + try { + parsedBaseUrl = new URL(baseUrl); + } catch { + fail("base_url_invalid"); + } + if (parsedBaseUrl.protocol !== "http:" || !LOOPBACK_HOSTS.has(parsedBaseUrl.hostname)) { + fail("base_url_not_loopback"); + } + + const pepper = requireValue(env, "AUTH_CODE_PEPPER"); + if (/^<[^>]+>$/.test(pepper) || Buffer.byteLength(pepper, "utf8") < 32) { + fail("auth_code_pepper_weak"); + } + const jwtSecret = requireValue(env, "JWT_SECRET"); + if (jwtSecret === pepper) fail("auth_code_pepper_not_independent"); + requireValue(env, "FIELD_ENCRYPTION_KEY"); + + return { + baseUrl: parsedBaseUrl.origin, + databaseUrl, + pepper, + }; +} + +function validateAuthConcurrencyEnvironment(env = process.env) { + if (env.RUN_AUTH_POSTGRES_CONCURRENCY !== "1") fail("explicit_opt_in_required"); + const baseContext = validateAuthRegressionEnvironment(env); + const employeeId = requireValue(env, "AUTH_SECURITY_EMPLOYEE_ID"); + if (!EMPLOYEE_ID_PATTERN.test(employeeId)) fail("employee_id_invalid"); + + return { + ...baseContext, + employeeId, + employeeEmail: requireValue(env, "AUTH_SECURITY_EMPLOYEE_EMAIL"), + currentPassword: requireValue(env, "AUTH_SECURITY_CURRENT_PASSWORD"), + }; +} + +function createStrongPassword(prefix) { + return `${prefix}A1!${crypto.randomBytes(12).toString("hex")}`; +} + +function assertCondition(condition, code) { + if (!condition) fail(code); +} + +async function postJson(baseUrl, path, payload, { headers = {}, signal } = {}) { + let response; + try { + response = await fetch(`${baseUrl}${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + ...headers, + }, + body: JSON.stringify(payload), + signal, + }); + } catch { + fail("http_request_failed"); + } + + let error = null; + if (response.status !== 200) { + try { + const body = await response.json(); + error = { + code: body?.error?.code ?? null, + detailsCode: body?.error?.details?.code ?? null, + }; + } catch { + error = null; + } + } + + return { + status: response.status, + error, + setCookie: Boolean(response.headers.get("set-cookie")), + }; +} + +function extractRefreshCookie(response) { + const setCookie = String(response.headers.get("set-cookie") ?? ""); + const match = setCookie.match(/(?:^|,\s*)refresh_token=([^;,\s]+)/); + return match ? `refresh_token=${match[1]}` : null; +} + +async function loginForRace({ baseUrl, email, password, fingerprint, signal }) { + let response; + try { + response = await fetch(`${baseUrl}/api/auth/login`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-device-fingerprint": fingerprint, + }, + body: JSON.stringify({ identifier: email, password }), + signal, + }); + } catch { + fail("login_http_request_failed"); + } + + let accessToken = null; + let error = null; + try { + const body = await response.json(); + if (response.status === 200) { + accessToken = typeof body?.data?.accessToken === "string" ? body.data.accessToken : null; + } else { + error = { + code: body?.error?.code ?? null, + detailsCode: body?.error?.details?.code ?? null, + }; + } + } catch { + error = null; + } + + return { + status: response.status, + accessToken, + refreshCookie: extractRefreshCookie(response), + error, + }; +} + +async function getWithAccessToken(baseUrl, path, accessToken) { + let response; + try { + response = await fetch(`${baseUrl}${path}`, { + headers: { authorization: `Bearer ${accessToken}` }, + }); + } catch { + fail("access_token_probe_failed"); + } + + let errorCode = null; + if (response.status !== 200) { + try { + const body = await response.json(); + errorCode = body?.error?.code ?? null; + } catch { + errorCode = null; + } + } + return { status: response.status, errorCode }; +} + +async function preparePasswordResetChallenge({ + prisma, + employeeId, + pepper, + includeSession, +}) { + const now = new Date(); + const expiresAt = new Date(now.getTime() + 10 * 60 * 1000); + const code = generateSixDigitCode(); + const emailCode = generateSixDigitCode(); + const codeHash = hashAuthCode({ + employeeId, + code, + purpose: "password_reset", + pepper, + }); + const emailCodeHash = hashAuthCode({ + employeeId, + code: emailCode, + purpose: "email_login", + pepper, + }); + const refreshToken = includeSession ? randomRefreshToken() : null; + + const created = await prisma.$transaction(async (tx) => { + await tx.employee.update({ + where: { id: employeeId }, + data: { + password_reset_failed_count: 0, + password_reset_failed_first_at: null, + password_reset_locked_until: null, + password_reset_request_count: 0, + password_reset_request_first_at: null, + password_reset_last_requested_at: null, + email_login_code_failed_count: 0, + email_login_code_failed_first_at: null, + email_login_code_locked_until: null, + email_login_code_last_requested_at: null, + }, + }); + await tx.passwordResetCode.updateMany({ + where: { employee_id: employeeId, invalidated_at: null }, + data: { invalidated_at: now }, + }); + await tx.emailLoginCode.updateMany({ + where: { employee_id: employeeId, used_at: null }, + data: { used_at: now }, + }); + + const resetCode = await tx.passwordResetCode.create({ + data: { + employee_id: employeeId, + code_hash: codeHash, + expires_at: expiresAt, + created_ip: "127.0.0.1", + created_user_agent: "auth-concurrency-check", + }, + select: { id: true }, + }); + const loginCode = await tx.emailLoginCode.create({ + data: { + employee_id: employeeId, + code_hash: emailCodeHash, + expires_at: expiresAt, + created_ip: "127.0.0.1", + created_user_agent: "auth-concurrency-check", + }, + select: { id: true }, + }); + + let refreshSessionId = null; + if (refreshToken) { + const refreshSession = await tx.authSession.create({ + data: { + employee_id: employeeId, + refresh_token_hash: sha256Hex(refreshToken), + device_fingerprint: "auth-concurrency-check", + ip_address: "127.0.0.1", + user_agent: "auth-concurrency-check", + expires_at: new Date(now.getTime() + 60 * 60 * 1000), + }, + select: { id: true }, + }); + refreshSessionId = refreshSession.id; + } + + return { resetCodeId: resetCode.id, emailCodeId: loginCode.id, refreshSessionId }; + }); + + return { ...created, code, refreshToken }; +} + +async function getEmployeeSecurityState(prisma, employeeId) { + return prisma.employee.findUnique({ + where: { id: employeeId }, + select: { + id: true, + email: true, + status: true, + password_hash: true, + token_version: true, + password_reset_failed_count: true, + password_reset_locked_until: true, + }, + }); +} + +async function countAudit(prisma, employeeId, action) { + return prisma.auditLog.count({ + where: { actor_id: employeeId, action }, + }); +} + +async function runConcurrentForgotPasswordRequests({ prisma, context }) { + const preparedAt = new Date(); + await prisma.$transaction(async (tx) => { + await tx.employee.update({ + where: { id: context.employeeId }, + data: { + password_reset_failed_count: 0, + password_reset_failed_first_at: null, + password_reset_locked_until: null, + password_reset_request_count: 0, + password_reset_request_first_at: null, + password_reset_last_requested_at: null, + }, + }); + await tx.passwordResetCode.updateMany({ + where: { employee_id: context.employeeId, invalidated_at: null }, + data: { invalidated_at: preparedAt }, + }); + }); + + const requestedAuditBefore = await countAudit( + prisma, + context.employeeId, + "AUTH_PASSWORD_RESET_REQUESTED", + ); + const responses = await Promise.all( + Array.from({ length: 20 }, () => + postJson(context.baseUrl, "/api/auth/forgot-password", { + email: context.employeeEmail, + }), + ), + ); + assertCondition( + responses.every((response) => response.status === 200 && !response.setCookie), + "forgot_concurrent_response_invalid", + ); + + const [employee, activeCodes, requestedAuditAfter] = await Promise.all([ + prisma.employee.findUnique({ + where: { id: context.employeeId }, + select: { + password_reset_failed_count: true, + password_reset_locked_until: true, + password_reset_request_count: true, + password_reset_request_first_at: true, + password_reset_last_requested_at: true, + }, + }), + prisma.passwordResetCode.count({ + where: { + employee_id: context.employeeId, + used_at: null, + invalidated_at: null, + expires_at: { gt: new Date() }, + }, + }), + countAudit(prisma, context.employeeId, "AUTH_PASSWORD_RESET_REQUESTED"), + ]); + + assertCondition(activeCodes === 1, "forgot_concurrent_active_code_count_invalid"); + assertCondition(employee?.password_reset_request_count === 1, "forgot_request_count_invalid"); + assertCondition( + employee?.password_reset_request_first_at instanceof Date && + employee?.password_reset_last_requested_at instanceof Date, + "forgot_request_timestamps_missing", + ); + assertCondition(employee?.password_reset_failed_count === 0, "forgot_failure_count_changed"); + assertCondition(employee?.password_reset_locked_until === null, "forgot_account_locked"); + assertCondition( + requestedAuditAfter === requestedAuditBefore + 1, + "forgot_request_audit_count_invalid", + ); +} + +async function runSameCodeConcurrency({ prisma, context }) { + const before = await getEmployeeSecurityState(prisma, context.employeeId); + assertCondition(before?.email === context.employeeEmail, "employee_identity_mismatch"); + assertCondition(before.status === "active", "employee_not_active"); + assertCondition( + await verifyPassword(context.currentPassword, before.password_hash), + "current_password_mismatch", + ); + + const challenge = await preparePasswordResetChallenge({ + prisma, + employeeId: context.employeeId, + pepper: context.pepper, + includeSession: true, + }); + const successAuditBefore = await countAudit( + prisma, + context.employeeId, + "AUTH_PASSWORD_RESET_SUCCEEDED", + ); + const newPassword = createStrongPassword("Concurrent"); + + const responses = await Promise.all( + Array.from({ length: 20 }, () => + postJson(context.baseUrl, "/api/auth/reset-password", { + email: context.employeeEmail, + code: challenge.code, + newPassword, + }), + ), + ); + const successes = responses.filter((response) => response.status === 200); + const failures = responses.filter((response) => response.status === 400); + if (successes.length !== 1) { + if ( + responses.every( + (response) => + response.status === 400 && + response.error?.code === "VALID_001" && + response.error?.detailsCode === "invalid_or_expired", + ) + ) { + const [rejectedCode, rejectedEmployee, activeCodeCount] = await Promise.all([ + prisma.passwordResetCode.findUnique({ + where: { id: challenge.resetCodeId }, + select: { attempts_count: true, used_at: true, invalidated_at: true }, + }), + prisma.employee.findUnique({ + where: { id: context.employeeId }, + select: { + password_reset_failed_count: true, + password_reset_locked_until: true, + }, + }), + prisma.passwordResetCode.count({ + where: { + employee_id: context.employeeId, + used_at: null, + invalidated_at: null, + expires_at: { gt: new Date() }, + }, + }), + ]); + if (Number(rejectedCode?.attempts_count ?? 0) > 0) { + fail("concurrent_code_hash_mismatch"); + } + if (rejectedEmployee?.password_reset_locked_until instanceof Date) { + fail("concurrent_account_prelocked"); + } + if (activeCodeCount === 0) { + fail("concurrent_code_not_active"); + } + fail("concurrent_active_code_not_selected"); + } + if ( + responses.every( + (response) => + response.status === 400 && + response.error?.code === "VALID_001" && + response.error?.detailsCode === null, + ) + ) { + fail("concurrent_all_payloads_rejected"); + } + fail("concurrent_success_count_invalid"); + } + assertCondition(failures.length === 19, "concurrent_failure_count_invalid"); + assertCondition( + failures.every( + (response) => + response.error?.code === "VALID_001" && + response.error?.detailsCode === "invalid_or_expired" && + !response.setCookie, + ), + "concurrent_failure_contract_invalid", + ); + + const after = await getEmployeeSecurityState(prisma, context.employeeId); + assertCondition(after.token_version === before.token_version + 1, "token_version_increment_invalid"); + assertCondition(await verifyPassword(newPassword, after.password_hash), "new_password_not_active"); + assertCondition( + !(await verifyPassword(context.currentPassword, after.password_hash)), + "old_password_still_active", + ); + assertCondition(after.password_reset_failed_count === 0, "concurrent_loser_failure_count_changed"); + assertCondition(after.password_reset_locked_until === null, "concurrent_losers_locked_account"); + + const [resetCode, emailCode, activeResetCodes, activeSessions, successAuditAfter] = + await Promise.all([ + prisma.passwordResetCode.findUnique({ + where: { id: challenge.resetCodeId }, + select: { used_at: true, invalidated_at: true }, + }), + prisma.emailLoginCode.findUnique({ + where: { id: challenge.emailCodeId }, + select: { used_at: true }, + }), + prisma.passwordResetCode.count({ + where: { + employee_id: context.employeeId, + used_at: null, + invalidated_at: null, + expires_at: { gt: new Date() }, + }, + }), + prisma.authSession.count({ + where: { + employee_id: context.employeeId, + revoked_at: null, + expires_at: { gt: new Date() }, + }, + }), + countAudit(prisma, context.employeeId, "AUTH_PASSWORD_RESET_SUCCEEDED"), + ]); + + assertCondition(resetCode?.used_at instanceof Date, "reset_code_not_consumed"); + assertCondition(resetCode?.invalidated_at instanceof Date, "reset_code_not_invalidated"); + assertCondition(emailCode?.used_at instanceof Date, "email_code_not_invalidated"); + assertCondition(activeResetCodes === 0, "active_reset_code_remains"); + assertCondition(activeSessions === 0, "active_session_remains"); + assertCondition(successAuditAfter === successAuditBefore + 1, "success_audit_count_invalid"); + + return newPassword; +} + +async function waitForBlockedConnections(observer, excludedPids, expectedCount, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const result = await observer.query( + `SELECT COUNT(*)::int AS count + FROM pg_stat_activity + WHERE datname = current_database() + AND pid <> $1 + AND pid <> $2 + AND cardinality(pg_blocking_pids(pid)) > 0`, + excludedPids, + ); + if (Number(result.rows[0]?.count ?? 0) >= expectedCount) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + fail("blocked_connection_order_timeout"); +} + +async function withTimeout(promise, timeoutMs, code) { + let timeout; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new AuthConcurrencyCheckError(code)), timeoutMs); + }), + ]); + } finally { + clearTimeout(timeout); + } +} + +async function runLoginResetRace({ prisma, context, currentPassword }) { + const before = await getEmployeeSecurityState(prisma, context.employeeId); + assertCondition( + await verifyPassword(currentPassword, before.password_hash), + "race_current_password_mismatch", + ); + const challenge = await preparePasswordResetChallenge({ + prisma, + employeeId: context.employeeId, + pepper: context.pepper, + includeSession: false, + }); + const successAuditBefore = await countAudit( + prisma, + context.employeeId, + "AUTH_PASSWORD_RESET_SUCCEEDED", + ); + const loginAuditBefore = await countAudit(prisma, context.employeeId, "AUTH_LOGIN_SUCCESS"); + const racePassword = createStrongPassword("RaceReset"); + + const holder = new Client({ connectionString: context.databaseUrl }); + const observer = new Client({ connectionString: context.databaseUrl }); + const resetAbort = new AbortController(); + const loginAbort = new AbortController(); + let holderInTransaction = false; + let resetPromise = null; + let loginPromise = null; + + try { + await holder.connect(); + await observer.connect(); + const holderPid = holder.processID; + const observerPid = observer.processID; + await holder.query("BEGIN"); + holderInTransaction = true; + await holder.query('SELECT id FROM "employees" WHERE id = $1::uuid FOR UPDATE', [ + context.employeeId, + ]); + + resetPromise = postJson( + context.baseUrl, + "/api/auth/reset-password", + { + email: context.employeeEmail, + code: challenge.code, + newPassword: racePassword, + }, + { signal: resetAbort.signal }, + ); + await waitForBlockedConnections(observer, [holderPid, observerPid], 1, 15_000); + + loginPromise = postJson( + context.baseUrl, + "/api/auth/login", + { + identifier: context.employeeEmail, + password: currentPassword, + }, + { + headers: { "x-device-fingerprint": "auth-reset-race" }, + signal: loginAbort.signal, + }, + ); + await waitForBlockedConnections(observer, [holderPid, observerPid], 2, 20_000); + + await holder.query("COMMIT"); + holderInTransaction = false; + const [resetResponse, loginResponse] = await Promise.all([ + withTimeout(resetPromise, 30_000, "race_reset_timeout"), + withTimeout(loginPromise, 30_000, "race_login_timeout"), + ]); + + assertCondition(resetResponse.status === 200, "race_reset_failed"); + assertCondition( + loginResponse.status === 401 && + loginResponse.error?.code === "AUTH_001" && + !loginResponse.setCookie, + "stale_login_was_accepted", + ); + } finally { + resetAbort.abort(); + loginAbort.abort(); + if (holderInTransaction) { + await holder.query("ROLLBACK").catch(() => undefined); + } + await Promise.allSettled([resetPromise, loginPromise].filter(Boolean)); + await holder.end().catch(() => undefined); + await observer.end().catch(() => undefined); + } + + const after = await getEmployeeSecurityState(prisma, context.employeeId); + const [activeSessions, successAuditAfter, loginAuditAfter] = await Promise.all([ + prisma.authSession.count({ + where: { + employee_id: context.employeeId, + revoked_at: null, + expires_at: { gt: new Date() }, + }, + }), + countAudit(prisma, context.employeeId, "AUTH_PASSWORD_RESET_SUCCEEDED"), + countAudit(prisma, context.employeeId, "AUTH_LOGIN_SUCCESS"), + ]); + assertCondition(after.token_version === before.token_version + 1, "race_token_version_invalid"); + assertCondition(await verifyPassword(racePassword, after.password_hash), "race_password_not_active"); + assertCondition( + !(await verifyPassword(currentPassword, after.password_hash)), + "race_old_password_still_active", + ); + assertCondition(activeSessions === 0, "race_active_session_remains"); + assertCondition(successAuditAfter === successAuditBefore + 1, "race_success_audit_invalid"); + assertCondition(loginAuditAfter === loginAuditBefore, "race_login_success_audit_created"); + return racePassword; +} + +async function runLoginFirstResetRace({ prisma, context, currentPassword }) { + const before = await getEmployeeSecurityState(prisma, context.employeeId); + assertCondition( + await verifyPassword(currentPassword, before.password_hash), + "login_first_current_password_mismatch", + ); + const challenge = await preparePasswordResetChallenge({ + prisma, + employeeId: context.employeeId, + pepper: context.pepper, + includeSession: false, + }); + const successAuditBefore = await countAudit( + prisma, + context.employeeId, + "AUTH_PASSWORD_RESET_SUCCEEDED", + ); + const loginAuditBefore = await countAudit(prisma, context.employeeId, "AUTH_LOGIN_SUCCESS"); + const nextPassword = createStrongPassword("LoginFirstReset"); + const fingerprint = "auth-login-first-reset"; + + const holder = new Client({ connectionString: context.databaseUrl }); + const observer = new Client({ connectionString: context.databaseUrl }); + const loginAbort = new AbortController(); + const resetAbort = new AbortController(); + let holderInTransaction = false; + let loginPromise = null; + let resetPromise = null; + let loginResponse = null; + let resetResponse = null; + + try { + await holder.connect(); + await observer.connect(); + const holderPid = holder.processID; + const observerPid = observer.processID; + await holder.query("BEGIN"); + holderInTransaction = true; + await holder.query('SELECT id FROM "employees" WHERE id = $1::uuid FOR UPDATE', [ + context.employeeId, + ]); + + loginPromise = loginForRace({ + baseUrl: context.baseUrl, + email: context.employeeEmail, + password: currentPassword, + fingerprint, + signal: loginAbort.signal, + }); + await waitForBlockedConnections(observer, [holderPid, observerPid], 1, 15_000); + + resetPromise = postJson( + context.baseUrl, + "/api/auth/reset-password", + { + email: context.employeeEmail, + code: challenge.code, + newPassword: nextPassword, + }, + { signal: resetAbort.signal }, + ); + await waitForBlockedConnections(observer, [holderPid, observerPid], 2, 20_000); + + await holder.query("COMMIT"); + holderInTransaction = false; + [loginResponse, resetResponse] = await Promise.all([ + withTimeout(loginPromise, 30_000, "login_first_login_timeout"), + withTimeout(resetPromise, 30_000, "login_first_reset_timeout"), + ]); + + assertCondition( + loginResponse.status === 200 && + typeof loginResponse.accessToken === "string" && + typeof loginResponse.refreshCookie === "string", + "login_first_login_failed", + ); + assertCondition(resetResponse.status === 200, "login_first_reset_failed"); + } finally { + loginAbort.abort(); + resetAbort.abort(); + if (holderInTransaction) { + await holder.query("ROLLBACK").catch(() => undefined); + } + await Promise.allSettled([loginPromise, resetPromise].filter(Boolean)); + await holder.end().catch(() => undefined); + await observer.end().catch(() => undefined); + } + + const [accessProbe, refreshProbe] = await Promise.all([ + getWithAccessToken(context.baseUrl, "/api/me", loginResponse.accessToken), + postJson( + context.baseUrl, + "/api/auth/refresh", + {}, + { + headers: { + cookie: loginResponse.refreshCookie, + origin: context.baseUrl, + "x-device-fingerprint": fingerprint, + }, + }, + ), + ]); + assertCondition( + accessProbe.status === 401 && accessProbe.errorCode === "AUTH_004", + "login_first_old_access_token_active", + ); + assertCondition( + refreshProbe.status === 401 && refreshProbe.error?.code === "AUTH_005", + "login_first_old_refresh_token_active", + ); + + const after = await getEmployeeSecurityState(prisma, context.employeeId); + const [activeSessions, successAuditAfter, loginAuditAfter] = await Promise.all([ + prisma.authSession.count({ + where: { + employee_id: context.employeeId, + revoked_at: null, + expires_at: { gt: new Date() }, + }, + }), + countAudit(prisma, context.employeeId, "AUTH_PASSWORD_RESET_SUCCEEDED"), + countAudit(prisma, context.employeeId, "AUTH_LOGIN_SUCCESS"), + ]); + assertCondition( + after.token_version === before.token_version + 1, + "login_first_token_version_invalid", + ); + assertCondition(await verifyPassword(nextPassword, after.password_hash), "login_first_password_not_active"); + assertCondition(activeSessions === 0, "login_first_session_not_revoked"); + assertCondition(successAuditAfter === successAuditBefore + 1, "login_first_reset_audit_invalid"); + assertCondition(loginAuditAfter === loginAuditBefore + 1, "login_first_login_audit_invalid"); + return nextPassword; +} + +async function runRefreshResetRace({ prisma, context, currentPassword }) { + const before = await getEmployeeSecurityState(prisma, context.employeeId); + assertCondition( + await verifyPassword(currentPassword, before.password_hash), + "refresh_race_current_password_mismatch", + ); + const challenge = await preparePasswordResetChallenge({ + prisma, + employeeId: context.employeeId, + pepper: context.pepper, + includeSession: true, + }); + assertCondition( + typeof challenge.refreshToken === "string" && typeof challenge.refreshSessionId === "string", + "refresh_race_session_missing", + ); + const successAuditBefore = await countAudit( + prisma, + context.employeeId, + "AUTH_PASSWORD_RESET_SUCCEEDED", + ); + const nextPassword = createStrongPassword("RefreshReset"); + + const holder = new Client({ connectionString: context.databaseUrl }); + const observer = new Client({ connectionString: context.databaseUrl }); + const resetAbort = new AbortController(); + const refreshAbort = new AbortController(); + let holderInTransaction = false; + let resetPromise = null; + let refreshPromise = null; + + try { + await holder.connect(); + await observer.connect(); + const holderPid = holder.processID; + const observerPid = observer.processID; + await holder.query("BEGIN"); + holderInTransaction = true; + await holder.query('SELECT id FROM "employees" WHERE id = $1::uuid FOR UPDATE', [ + context.employeeId, + ]); + + resetPromise = postJson( + context.baseUrl, + "/api/auth/reset-password", + { + email: context.employeeEmail, + code: challenge.code, + newPassword: nextPassword, + }, + { signal: resetAbort.signal }, + ); + await waitForBlockedConnections(observer, [holderPid, observerPid], 1, 15_000); + + refreshPromise = postJson( + context.baseUrl, + "/api/auth/refresh", + {}, + { + headers: { + cookie: `refresh_token=${challenge.refreshToken}`, + origin: context.baseUrl, + "x-device-fingerprint": "auth-concurrency-check", + }, + signal: refreshAbort.signal, + }, + ); + await waitForBlockedConnections(observer, [holderPid, observerPid], 2, 20_000); + + await holder.query("COMMIT"); + holderInTransaction = false; + const [resetResponse, refreshResponse] = await Promise.all([ + withTimeout(resetPromise, 30_000, "refresh_race_reset_timeout"), + withTimeout(refreshPromise, 30_000, "refresh_race_refresh_timeout"), + ]); + assertCondition(resetResponse.status === 200, "refresh_race_reset_failed"); + assertCondition( + refreshResponse.status === 401 && refreshResponse.error?.code === "AUTH_005", + "refresh_rotated_after_reset", + ); + } finally { + resetAbort.abort(); + refreshAbort.abort(); + if (holderInTransaction) { + await holder.query("ROLLBACK").catch(() => undefined); + } + await Promise.allSettled([resetPromise, refreshPromise].filter(Boolean)); + await holder.end().catch(() => undefined); + await observer.end().catch(() => undefined); + } + + const after = await getEmployeeSecurityState(prisma, context.employeeId); + const [session, activeSessions, successAuditAfter] = await Promise.all([ + prisma.authSession.findUnique({ + where: { id: challenge.refreshSessionId }, + select: { revoked_at: true }, + }), + prisma.authSession.count({ + where: { + employee_id: context.employeeId, + revoked_at: null, + expires_at: { gt: new Date() }, + }, + }), + countAudit(prisma, context.employeeId, "AUTH_PASSWORD_RESET_SUCCEEDED"), + ]); + assertCondition(after.token_version === before.token_version + 1, "refresh_race_token_version_invalid"); + assertCondition(await verifyPassword(nextPassword, after.password_hash), "refresh_race_password_not_active"); + assertCondition(session?.revoked_at instanceof Date, "refresh_race_original_session_active"); + assertCondition(activeSessions === 0, "refresh_race_active_session_remains"); + assertCondition(successAuditAfter === successAuditBefore + 1, "refresh_race_audit_invalid"); + return nextPassword; +} + +async function runAuthPostgresConcurrencyCheck(env = process.env) { + const context = validateAuthConcurrencyEnvironment(env); + const { prisma, pool } = createPrismaClient(); + try { + console.log("[AUTH-CONCURRENCY] forgot-password-20: start"); + await runConcurrentForgotPasswordRequests({ prisma, context }); + console.log("[AUTH-CONCURRENCY] forgot-password-20: pass"); + console.log("[AUTH-CONCURRENCY] same-code-20: start"); + let currentPassword = await runSameCodeConcurrency({ prisma, context }); + console.log("[AUTH-CONCURRENCY] same-code-20: pass"); + console.log("[AUTH-CONCURRENCY] reset-first-login-race: start"); + currentPassword = await runLoginResetRace({ prisma, context, currentPassword }); + console.log("[AUTH-CONCURRENCY] reset-first-login-race: pass"); + console.log("[AUTH-CONCURRENCY] login-first-reset-race: start"); + currentPassword = await runLoginFirstResetRace({ prisma, context, currentPassword }); + console.log("[AUTH-CONCURRENCY] login-first-reset-race: pass"); + console.log("[AUTH-CONCURRENCY] refresh-reset-race: start"); + await runRefreshResetRace({ prisma, context, currentPassword }); + console.log("[AUTH-CONCURRENCY] refresh-reset-race: pass"); + } finally { + await prisma.$disconnect().catch(() => undefined); + await pool.end().catch(() => undefined); + } +} + +if (require.main === module) { + runAuthPostgresConcurrencyCheck().catch((error) => { + const code = + error instanceof AuthConcurrencyCheckError + ? error.safeCode + : "auth_concurrency_check_failed"; + console.error(`[AUTH-CONCURRENCY][FAIL] ${code}`); + process.exitCode = 1; + }); +} + +module.exports = { + AuthConcurrencyCheckError, + runAuthPostgresConcurrencyCheck, + validateAuthConcurrencyEnvironment, + validateAuthRegressionEnvironment, +}; diff --git a/server/src/scripts/start-auth-security-test-server.js b/server/src/scripts/start-auth-security-test-server.js new file mode 100644 index 00000000..0aef66e8 --- /dev/null +++ b/server/src/scripts/start-auth-security-test-server.js @@ -0,0 +1,50 @@ +require("dotenv/config"); + +const { + validateAuthRegressionEnvironment, +} = require("./check-auth-postgres-concurrency"); + +try { + validateAuthRegressionEnvironment(process.env); +} catch { + console.error("[AUTH-TEST-SERVER][FAIL] environment_guard_rejected"); + process.exit(1); +} + +const { buildApp } = require("../app"); + +const disabledExternalEmailService = Object.freeze({ + resolveEmailDeliveryStatus() { + return { ready: true, reasons: [] }; + }, + async sendNotificationEmailSafe() { + return { email: { skipped: true, sent: 0, failed: 0 } }; + }, +}); + +async function main() { + const app = await buildApp({ authEmailService: disabledExternalEmailService }); + const { host, port } = app.config; + await app.listen({ host, port }); + + let shuttingDown = false; + const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + try { + await app.close(); + process.exit(0); + } catch { + console.error("[AUTH-TEST-SERVER][FAIL] shutdown_failed"); + process.exit(1); + } + }; + + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); +} + +main().catch(() => { + console.error("[AUTH-TEST-SERVER][FAIL] startup_failed"); + process.exit(1); +}); diff --git a/server/src/utils/crypto.js b/server/src/utils/crypto.js index 836b9db5..c99f0a80 100644 --- a/server/src/utils/crypto.js +++ b/server/src/utils/crypto.js @@ -5,6 +5,32 @@ function sha256Hex(input) { return crypto.createHash("sha256").update(input).digest("hex"); } +function generateSixDigitCode() { + return String(crypto.randomInt(0, 1_000_000)).padStart(6, "0"); +} + +function hashAuthCode({ employeeId, code, purpose, pepper }) { + return crypto + .createHmac("sha256", pepper) + .update(`${purpose}:${employeeId}:${code}`) + .digest("hex"); +} + +function timingSafeEqualHex(left, right) { + if ( + typeof left !== "string" || + typeof right !== "string" || + !/^[0-9a-f]+$/i.test(left) || + !/^[0-9a-f]+$/i.test(right) + ) { + return false; + } + + const leftBuffer = Buffer.from(left, "hex"); + const rightBuffer = Buffer.from(right, "hex"); + return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + function randomRefreshToken() { return crypto.randomBytes(32).toString("base64url"); } @@ -19,9 +45,11 @@ async function verifyPassword(password, passwordHash) { } module.exports = { + generateSixDigitCode, hashPassword, + hashAuthCode, randomRefreshToken, sha256Hex, + timingSafeEqualHex, verifyPassword, }; - diff --git a/server/tests/unit/app.auth-security-config.test.js b/server/tests/unit/app.auth-security-config.test.js new file mode 100644 index 00000000..fa2e89c3 --- /dev/null +++ b/server/tests/unit/app.auth-security-config.test.js @@ -0,0 +1,173 @@ +const crypto = require("node:crypto"); +const fp = require("fastify-plugin"); + +const { buildApp } = require("../../src/app"); +const { getConfig } = require("../../src/config"); +const { + generateSixDigitCode, + hashAuthCode, + timingSafeEqualHex, +} = require("../../src/utils/crypto"); + +const STRONG_PEPPER = "test-auth-code-pepper-with-at-least-32-bytes"; + +function stubBaseEnv(nodeEnv = "test") { + vi.stubEnv("NODE_ENV", nodeEnv); + vi.stubEnv("JWT_SECRET", "test-jwt-secret"); + vi.stubEnv("DATABASE_URL", ""); + vi.stubEnv("AUTH_CODE_PEPPER", nodeEnv === "production" ? STRONG_PEPPER : ""); + vi.stubEnv("TRUST_PROXY_HOPS", nodeEnv === "production" ? "1" : undefined); + vi.stubEnv("JOBS_ENABLED", "false"); + vi.stubEnv("METRICS_ENABLED", "false"); + vi.stubEnv("SERVE_CLIENT_DIST", "false"); + vi.stubEnv("OPENAPI_DOCS_ENABLED", "false"); +} + +function createPrismaPlugin() { + return fp(async (app) => { + app.decorate("prisma", { + $queryRaw: vi.fn().mockResolvedValue([{ ok: 1 }]), + employee: { + findUnique: vi.fn(), + }, + }); + }, { name: "test-prisma" }); +} + +async function buildIpProbeApp() { + const app = await buildApp({ + prismaPlugin: createPrismaPlugin(), + startJobs: vi.fn(async () => ({ driver: "disabled" })), + }); + app.get("/__test/ip", async (request) => ({ + ip: request.ip, + ips: request.ips, + })); + return app; +} + +describe("auth security configuration", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it("uses crypto.randomInt for padded six-digit codes", () => { + const randomSpy = vi.spyOn(crypto, "randomInt").mockReturnValue(42); + + expect(generateSixDigitCode()).toBe("000042"); + expect(randomSpy).toHaveBeenCalledWith(0, 1_000_000); + }); + + it("separates auth code hashes by purpose and compares them safely", () => { + const common = { + employeeId: "11111111-1111-4111-8111-111111111111", + code: "123456", + pepper: STRONG_PEPPER, + }; + const emailHash = hashAuthCode({ ...common, purpose: "email_login" }); + const resetHash = hashAuthCode({ ...common, purpose: "password_reset" }); + + expect(emailHash).not.toBe(resetHash); + expect(timingSafeEqualHex(emailHash, emailHash)).toBe(true); + expect(timingSafeEqualHex(emailHash, resetHash)).toBe(false); + expect(timingSafeEqualHex(emailHash, "not-hex")).toBe(false); + }); + + it("fails closed when production has no auth code pepper", () => { + stubBaseEnv("production"); + vi.stubEnv("AUTH_CODE_PEPPER", ""); + + expect(() => getConfig()).toThrow("Missing required env: AUTH_CODE_PEPPER"); + }); + + it.each([ + "", + "short-secret", + ])("rejects a weak production auth code pepper", (pepper) => { + stubBaseEnv("production"); + vi.stubEnv("AUTH_CODE_PEPPER", pepper); + + expect(() => getConfig()).toThrow("AUTH_CODE_PEPPER must be a non-placeholder secret"); + }); + + it("rejects a production auth code pepper reused as the JWT secret", () => { + stubBaseEnv("production"); + vi.stubEnv("JWT_SECRET", STRONG_PEPPER); + vi.stubEnv("AUTH_CODE_PEPPER", STRONG_PEPPER); + + expect(() => getConfig()).toThrow("AUTH_CODE_PEPPER must be independent from JWT_SECRET"); + }); + + it("forbids development password reset code logging in production", () => { + stubBaseEnv("production"); + vi.stubEnv("DEV_SHOW_PASSWORD_RESET_CODE", "true"); + + expect(() => getConfig()).toThrow("DEV_SHOW_PASSWORD_RESET_CODE is forbidden in production"); + }); + + it.each([ + [undefined, "Missing required env: TRUST_PROXY_HOPS"], + ["", "Invalid TRUST_PROXY_HOPS"], + ["0", "Invalid TRUST_PROXY_HOPS"], + ["2", "Invalid TRUST_PROXY_HOPS"], + ["true", "Invalid TRUST_PROXY_HOPS"], + ])("requires exactly one trusted proxy hop in production", (value, error) => { + stubBaseEnv("production"); + vi.stubEnv("TRUST_PROXY_HOPS", value); + + expect(() => getConfig()).toThrow(error); + }); + + it.each([ + ["PASSWORD_RESET_MAX_ATTEMPTS", "0"], + ["PASSWORD_RESET_LOCK_SECONDS", "-1"], + ["EMAIL_LOGIN_CODE_TTL_SECONDS", "NaN"], + ["EMAIL_LOGIN_CODE_MAX_ATTEMPTS", "21"], + ])("rejects unsafe numeric auth configuration", (name, value) => { + stubBaseEnv(); + vi.stubEnv(name, value); + + expect(() => getConfig()).toThrow(`Invalid ${name}`); + }); + + it("ignores a forged X-Forwarded-For header by default", async () => { + stubBaseEnv(); + const app = await buildIpProbeApp(); + + try { + const response = await app.inject({ + method: "GET", + url: "/__test/ip", + headers: { "x-forwarded-for": "10.10.10.10" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ ip: "127.0.0.1" }); + } finally { + await app.close(); + } + }, 20_000); + + it("trusts only the nearest address supplied by one controlled proxy", async () => { + stubBaseEnv(); + vi.stubEnv("TRUST_PROXY_HOPS", "1"); + const app = await buildIpProbeApp(); + + try { + const response = await app.inject({ + method: "GET", + url: "/__test/ip", + headers: { "x-forwarded-for": "10.10.10.10, 192.168.1.20" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + ip: "192.168.1.20", + ips: ["127.0.0.1", "192.168.1.20"], + }); + } finally { + await app.close(); + } + }, 20_000); +}); diff --git a/server/tests/unit/routes/auth.login.test.js b/server/tests/unit/routes/auth.login.test.js index 4246668f..f05288b5 100644 --- a/server/tests/unit/routes/auth.login.test.js +++ b/server/tests/unit/routes/auth.login.test.js @@ -4,21 +4,33 @@ const fastifyCookie = require("@fastify/cookie"); const authRoutes = require("../../../src/routes/auth"); const { AppError } = require("../../../src/utils/AppError"); -const { hashPassword, sha256Hex } = require("../../../src/utils/crypto"); +const { hashAuthCode, hashPassword, verifyPassword } = require("../../../src/utils/crypto"); const { signPayload } = require("../../../src/utils/signedUrl"); const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111"; const JWT_SECRET = "test-auth-secret"; +const AUTH_CODE_PEPPER = "test-auth-code-pepper-with-at-least-32-bytes"; function hashEmailLoginCode(code) { - return crypto.createHmac("sha256", JWT_SECRET).update(`${EMPLOYEE_ID}:${code}`).digest("hex"); + return hashAuthCode({ + employeeId: EMPLOYEE_ID, + code, + purpose: "email_login", + pepper: AUTH_CODE_PEPPER, + }); } -async function buildApp(passwordHash) { - const app = Fastify(); - await app.register(fastifyCookie); +function hashPasswordResetCode(code) { + return hashAuthCode({ + employeeId: EMPLOYEE_ID, + code, + purpose: "password_reset", + pepper: AUTH_CODE_PEPPER, + }); +} - const employeeFindFirst = vi.fn().mockResolvedValue({ +function buildEmployee(passwordHash) { + return { id: EMPLOYEE_ID, status: "active", password_hash: passwordHash, @@ -27,9 +39,33 @@ async function buildApp(passwordHash) { login_failed_count: 0, login_failed_first_at: null, login_locked_until: null, - }); + email_login_code_failed_count: 0, + email_login_code_failed_first_at: null, + email_login_code_locked_until: null, + email_login_code_last_requested_at: null, + password_reset_failed_count: 0, + password_reset_failed_first_at: null, + password_reset_locked_until: null, + password_reset_request_count: 0, + password_reset_request_first_at: null, + password_reset_last_requested_at: null, + }; +} + +async function buildApp(passwordHash) { + const app = Fastify(); + await app.register(fastifyCookie); + + const employee = buildEmployee(passwordHash); + const employeeFindFirst = vi.fn().mockResolvedValue(employee); + const employeeFindUnique = vi.fn().mockResolvedValue(employee); const employeeFindMany = vi.fn().mockResolvedValue([]); const employeeUpdate = vi.fn().mockResolvedValue({}); + const employeeUpdateMany = vi.fn().mockResolvedValue({ count: 1 }); + const queryRaw = vi.fn().mockResolvedValue([employee]); + const authSessionFindFirst = vi.fn().mockResolvedValue(null); + const txAuthSessionFindFirst = vi.fn().mockResolvedValue(null); + const authSessionFindUnique = vi.fn().mockResolvedValue(null); const authSessionFindMany = vi.fn().mockResolvedValue([]); const authSessionUpdateMany = vi.fn().mockResolvedValue({ count: 0 }); const authSessionCreate = vi.fn().mockResolvedValue({ id: "session-1" }); @@ -37,25 +73,36 @@ async function buildApp(passwordHash) { const notificationCreateMany = vi.fn().mockResolvedValue({ count: 1 }); const emailLoginCodeCreate = vi.fn().mockResolvedValue({ id: "email-code-1" }); const emailLoginCodeFindFirst = vi.fn().mockResolvedValue(null); - const emailLoginCodeUpdate = vi.fn().mockResolvedValue({ id: "email-code-1" }); const emailLoginCodeUpdateMany = vi.fn().mockResolvedValue({ count: 1 }); const passwordResetCodeCreate = vi.fn().mockResolvedValue({ id: "password-reset-code-1" }); + const passwordResetCodeFindFirst = vi.fn().mockResolvedValue(null); + const passwordResetCodeUpdateMany = vi.fn().mockResolvedValue({ count: 1 }); const wecomFindFirst = vi.fn().mockResolvedValue(null); const wecomUpsert = vi.fn().mockResolvedValue({ id: "binding-1" }); const tx = { + $queryRaw: queryRaw, employee: { update: employeeUpdate, + updateMany: employeeUpdateMany, }, authSession: { + findFirst: txAuthSessionFindFirst, + findUnique: authSessionFindUnique, findMany: authSessionFindMany, updateMany: authSessionUpdateMany, create: authSessionCreate, }, emailLoginCode: { - update: emailLoginCodeUpdate, + create: emailLoginCodeCreate, + findFirst: emailLoginCodeFindFirst, updateMany: emailLoginCodeUpdateMany, }, + passwordResetCode: { + create: passwordResetCodeCreate, + findFirst: passwordResetCodeFindFirst, + updateMany: passwordResetCodeUpdateMany, + }, auditLog: { create: auditCreate, }, @@ -78,7 +125,16 @@ async function buildApp(passwordHash) { loginFailMaxAttempts: 5, emailLoginCodeTtlSeconds: 600, emailLoginCodeMaxAttempts: 5, + emailLoginCodeFailWindowSeconds: 1800, + emailLoginCodeLockSeconds: 1800, passwordResetCodeTtlSeconds: 600, + passwordResetFailWindowSeconds: 1800, + passwordResetMaxAttempts: 5, + passwordResetLockSeconds: 1800, + passwordResetRequestWindowSeconds: 3600, + passwordResetRequestMax: 3, + authCodeRequestCooldownSeconds: 60, + devShowPasswordResetCode: false, emailEnabled: true, emailFrom: "no-reply@hyinda.test", smtpHost: "smtp.hyinda.test", @@ -88,31 +144,32 @@ async function buildApp(passwordHash) { smtpPass: "", rateLimitEnabled: false, jwtSecret: JWT_SECRET, + authCodePepper: AUTH_CODE_PEPPER, appOrigin: "http://127.0.0.1:3000", wecomAllowedReturnHosts: "127.0.0.1:3000", }); + const transaction = vi.fn(async (callback) => callback(tx)); app.decorate("prisma", { employee: { findFirst: employeeFindFirst, + findUnique: employeeFindUnique, findMany: employeeFindMany, }, - emailLoginCode: { - create: emailLoginCodeCreate, - findFirst: emailLoginCodeFindFirst, - }, - passwordResetCode: { - create: passwordResetCodeCreate, - }, auditLog: { create: auditCreate, }, notification: { createMany: notificationCreateMany, }, - $transaction: vi.fn(async (callback) => callback(tx)), + authSession: { + findFirst: authSessionFindFirst, + }, + $transaction: transaction, }); app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token")); - app.decorate("authenticate", async () => undefined); + app.decorate("authenticate", async (request) => { + request.employee = { id: EMPLOYEE_ID }; + }); app.setErrorHandler((error, request, reply) => { if (error instanceof AppError) { return reply.status(error.statusCode).send({ @@ -131,8 +188,15 @@ async function buildApp(passwordHash) { return { app, employeeFindFirst, + employeeFindUnique, employeeFindMany, employeeUpdate, + employeeUpdateMany, + queryRaw, + transaction, + authSessionFindFirst, + txAuthSessionFindFirst, + authSessionFindUnique, authSessionFindMany, authSessionUpdateMany, authSessionCreate, @@ -140,9 +204,10 @@ async function buildApp(passwordHash) { notificationCreateMany, emailLoginCodeCreate, emailLoginCodeFindFirst, - emailLoginCodeUpdate, emailLoginCodeUpdateMany, passwordResetCodeCreate, + passwordResetCodeFindFirst, + passwordResetCodeUpdateMany, wecomFindFirst, wecomUpsert, }; @@ -370,8 +435,8 @@ describe("auth login route", () => { it("requests an email login code without exposing account existence", async () => { const passwordHash = await hashPassword("AdminPassword1234"); - const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.123456); - const { app, emailLoginCodeCreate, auditCreate } = await buildApp(passwordHash); + const randomSpy = vi.spyOn(crypto, "randomInt").mockReturnValue(123456); + const { app, emailLoginCodeCreate, emailLoginCodeUpdateMany, auditCreate } = await buildApp(passwordHash); const response = await app.inject({ method: "POST", @@ -388,6 +453,10 @@ describe("auth login route", () => { code_hash: hashEmailLoginCode("123456"), }), }); + expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({ + where: { employee_id: EMPLOYEE_ID, used_at: null }, + data: { used_at: expect.any(Date) }, + }); expect(auditCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "AUTH_EMAIL_LOGIN_CODE_REQUESTED", @@ -401,7 +470,7 @@ describe("auth login route", () => { it("rejects email login code requests when delivery is not configured before account lookup", async () => { const passwordHash = await hashPassword("AdminPassword1234"); - const { app, employeeFindFirst, emailLoginCodeCreate } = await buildApp(passwordHash); + const { app, transaction, queryRaw, emailLoginCodeCreate } = await buildApp(passwordHash); app.config.emailEnabled = false; const response = await app.inject({ @@ -413,7 +482,8 @@ describe("auth login route", () => { expect(response.statusCode).toBe(503); expect(response.json().error.details).toEqual({ email: "delivery_not_configured" }); - expect(employeeFindFirst).not.toHaveBeenCalled(); + expect(transaction).not.toHaveBeenCalled(); + expect(queryRaw).not.toHaveBeenCalled(); expect(emailLoginCodeCreate).not.toHaveBeenCalled(); await app.close(); @@ -421,8 +491,8 @@ describe("auth login route", () => { it("does not reveal missing accounts when requesting an email login code", async () => { const passwordHash = await hashPassword("AdminPassword1234"); - const { app, employeeFindFirst, emailLoginCodeCreate } = await buildApp(passwordHash); - employeeFindFirst.mockResolvedValueOnce(null); + const { app, queryRaw, emailLoginCodeCreate } = await buildApp(passwordHash); + queryRaw.mockResolvedValueOnce([]); const response = await app.inject({ method: "POST", @@ -465,7 +535,13 @@ describe("auth login route", () => { }), ); expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({ - where: { id: "email-code-1", used_at: null }, + where: { + id: "email-code-1", + employee_id: EMPLOYEE_ID, + code_hash: hashEmailLoginCode("654321"), + used_at: null, + expires_at: { gt: expect.any(Date) }, + }, data: { used_at: expect.any(Date) }, }); expect(authSessionCreate).toHaveBeenCalledWith({ @@ -492,7 +568,7 @@ describe("auth login route", () => { it("rejects an invalid email login code and records the failed attempt", async () => { const passwordHash = await hashPassword("AdminPassword1234"); - const { app, emailLoginCodeFindFirst, emailLoginCodeUpdate, auditCreate } = await buildApp(passwordHash); + const { app, employeeUpdate, emailLoginCodeFindFirst, emailLoginCodeUpdateMany, auditCreate } = await buildApp(passwordHash); emailLoginCodeFindFirst.mockResolvedValueOnce({ id: "email-code-1", code_hash: hashEmailLoginCode("654321"), @@ -511,10 +587,17 @@ describe("auth login route", () => { expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ code: "invalid_or_expired" }); - expect(emailLoginCodeUpdate).toHaveBeenCalledWith({ - where: { id: "email-code-1" }, + expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({ + where: { id: "email-code-1", used_at: null }, data: { attempts_count: { increment: 1 } }, }); + expect(employeeUpdate).toHaveBeenCalledWith({ + where: { id: EMPLOYEE_ID }, + data: expect.objectContaining({ + email_login_code_failed_count: 1, + email_login_code_locked_until: null, + }), + }); expect(auditCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "AUTH_EMAIL_LOGIN_FAILED", @@ -527,8 +610,14 @@ describe("auth login route", () => { it("sends password reset codes with Beijing-time expiry wording", async () => { const passwordHash = await hashPassword("AdminPassword1234"); - const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.654321); - const { app, passwordResetCodeCreate, auditCreate, notificationCreateMany } = await buildApp(passwordHash); + const randomSpy = vi.spyOn(crypto, "randomInt").mockReturnValue(654321); + const { + app, + passwordResetCodeCreate, + passwordResetCodeUpdateMany, + auditCreate, + notificationCreateMany, + } = await buildApp(passwordHash); const response = await app.inject({ method: "POST", @@ -542,9 +631,17 @@ describe("auth login route", () => { expect(passwordResetCodeCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ employee_id: EMPLOYEE_ID, - code_hash: sha256Hex("654321"), + code_hash: hashPasswordResetCode("654321"), }), }); + expect(passwordResetCodeUpdateMany).toHaveBeenCalledWith({ + where: { + employee_id: EMPLOYEE_ID, + used_at: null, + invalidated_at: null, + }, + data: { invalidated_at: expect.any(Date) }, + }); expect(auditCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "AUTH_PASSWORD_RESET_REQUESTED", @@ -559,7 +656,7 @@ describe("auth login route", () => { it("rejects password reset code requests when delivery is not configured before account lookup", async () => { const passwordHash = await hashPassword("AdminPassword1234"); - const { app, employeeFindFirst, passwordResetCodeCreate } = await buildApp(passwordHash); + const { app, transaction, queryRaw, passwordResetCodeCreate } = await buildApp(passwordHash); app.config.smtpHost = ""; const response = await app.inject({ @@ -571,9 +668,579 @@ describe("auth login route", () => { expect(response.statusCode).toBe(503); expect(response.json().error.details).toEqual({ email: "delivery_not_configured" }); - expect(employeeFindFirst).not.toHaveBeenCalled(); + expect(transaction).not.toHaveBeenCalled(); + expect(queryRaw).not.toHaveBeenCalled(); expect(passwordResetCodeCreate).not.toHaveBeenCalled(); await app.close(); }); + + it.each([ + ["missing", []], + ["archived", [{ ...buildEmployee("unused"), status: "archived" }]], + [ + "locked", + [ + { + ...buildEmployee("unused"), + password_reset_locked_until: new Date(Date.now() + 60_000), + }, + ], + ], + ])("returns the same forgot-password response without writes for a %s account", async (_case, rows) => { + const passwordHash = await hashPassword("AdminPassword1234"); + const { + app, + queryRaw, + employeeUpdate, + passwordResetCodeCreate, + passwordResetCodeUpdateMany, + auditCreate, + } = await buildApp(passwordHash); + queryRaw.mockResolvedValueOnce(rows); + + const response = await app.inject({ + method: "POST", + url: "/api/auth/forgot-password", + headers: { "content-type": "application/json" }, + payload: { email: "account@hyinda.test" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual({}); + expect(employeeUpdate).not.toHaveBeenCalled(); + expect(passwordResetCodeCreate).not.toHaveBeenCalled(); + expect(passwordResetCodeUpdateMany).not.toHaveBeenCalled(); + expect(auditCreate).not.toHaveBeenCalled(); + + await app.close(); + }); + + it("does not create a session when password state changed after verification", async () => { + const passwordHash = await hashPassword("AdminPassword1234"); + const { app, employeeUpdateMany, authSessionCreate } = await buildApp(passwordHash); + employeeUpdateMany.mockResolvedValueOnce({ count: 0 }); + + const response = await app.inject({ + method: "POST", + url: "/api/auth/login", + headers: { + "content-type": "application/json", + "x-device-fingerprint": "stale-password-login", + }, + payload: { + identifier: "employee@hyinda.test", + password: "AdminPassword1234", + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().error.code).toBe("AUTH_001"); + expect(authSessionCreate).not.toHaveBeenCalled(); + expect(response.headers["set-cookie"]).toBeUndefined(); + + await app.close(); + }); + + it("locks and rechecks the employee before rotating a refresh session", async () => { + const passwordHash = await hashPassword("AdminPassword1234"); + const { + app, + employeeFindUnique, + queryRaw, + authSessionFindFirst, + authSessionFindUnique, + authSessionUpdateMany, + authSessionCreate, + } = await buildApp(passwordHash); + const activeSession = { + id: "session-1", + employee_id: EMPLOYEE_ID, + device_fingerprint: "refresh-device", + expires_at: new Date(Date.now() + 60_000), + revoked_at: null, + }; + authSessionFindFirst.mockResolvedValueOnce(activeSession); + authSessionFindUnique.mockResolvedValueOnce(activeSession); + authSessionUpdateMany.mockResolvedValueOnce({ count: 1 }); + + const response = await app.inject({ + method: "POST", + url: "/api/auth/refresh", + headers: { + cookie: "refresh_token=old-refresh-token", + origin: "http://127.0.0.1:3000", + "x-device-fingerprint": "refresh-device", + }, + payload: {}, + }); + + expect(response.statusCode).toBe(200); + expect(queryRaw).toHaveBeenCalledTimes(1); + expect(authSessionFindUnique).toHaveBeenCalledWith({ + where: { id: "session-1" }, + select: expect.objectContaining({ id: true, employee_id: true, revoked_at: true }), + }); + expect(queryRaw.mock.invocationCallOrder[0]).toBeLessThan( + authSessionFindUnique.mock.invocationCallOrder[0], + ); + expect(employeeFindUnique).not.toHaveBeenCalled(); + expect(authSessionCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + employee_id: EMPLOYEE_ID, + device_fingerprint: "refresh-device", + }), + }); + + await app.close(); + }); + + it("suppresses email login code requests during the account cooldown", async () => { + const passwordHash = await hashPassword("AdminPassword1234"); + const { app, queryRaw, emailLoginCodeCreate, auditCreate } = await buildApp(passwordHash); + queryRaw.mockResolvedValueOnce([ + { + ...buildEmployee(passwordHash), + email_login_code_last_requested_at: new Date(), + }, + ]); + + const response = await app.inject({ + method: "POST", + url: "/api/auth/email-login/code", + headers: { "content-type": "application/json" }, + payload: { email: "employee@hyinda.test" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual({}); + expect(emailLoginCodeCreate).not.toHaveBeenCalled(); + expect(auditCreate).not.toHaveBeenCalled(); + + await app.close(); + }); + + it("locks the account-level email code entry on the fifth failed attempt", async () => { + const passwordHash = await hashPassword("AdminPassword1234"); + const { + app, + queryRaw, + employeeUpdate, + emailLoginCodeFindFirst, + emailLoginCodeUpdateMany, + auditCreate, + } = await buildApp(passwordHash); + queryRaw.mockResolvedValueOnce([ + { + ...buildEmployee(passwordHash), + email_login_code_failed_count: 4, + email_login_code_failed_first_at: new Date(), + }, + ]); + emailLoginCodeFindFirst.mockResolvedValueOnce({ + id: "email-code-1", + code_hash: hashEmailLoginCode("654321"), + attempts_count: 4, + }); + + const response = await app.inject({ + method: "POST", + url: "/api/auth/email-login/verify", + headers: { + "content-type": "application/json", + "x-device-fingerprint": "email-lock-test", + }, + payload: { email: "employee@hyinda.test", code: "000000" }, + }); + + expect(response.statusCode).toBe(400); + expect(employeeUpdate).toHaveBeenCalledWith({ + where: { id: EMPLOYEE_ID }, + data: expect.objectContaining({ + email_login_code_failed_count: 5, + email_login_code_locked_until: expect.any(Date), + }), + }); + expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({ + where: { id: "email-code-1", used_at: null }, + data: { + attempts_count: { increment: 1 }, + used_at: expect.any(Date), + }, + }); + expect(auditCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ action: "AUTH_EMAIL_LOGIN_LOCKED" }), + }); + + await app.close(); + }); + + it("does not count a failure when no active email-login challenge exists", async () => { + const passwordHash = await hashPassword("AdminPassword1234"); + const { app, employeeUpdate, emailLoginCodeUpdateMany, auditCreate } = await buildApp(passwordHash); + + const response = await app.inject({ + method: "POST", + url: "/api/auth/email-login/verify", + headers: { + "content-type": "application/json", + "x-device-fingerprint": "missing-email-challenge", + }, + payload: { email: "employee@hyinda.test", code: "000000" }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error.details).toEqual({ code: "invalid_or_expired" }); + expect(employeeUpdate).not.toHaveBeenCalled(); + expect(emailLoginCodeUpdateMany).not.toHaveBeenCalled(); + expect(auditCreate).not.toHaveBeenCalled(); + + await app.close(); + }); + + it("keeps password reset failure counts when a new request is suppressed", async () => { + const passwordHash = await hashPassword("AdminPassword1234"); + const { app, queryRaw, employeeUpdate, passwordResetCodeCreate } = await buildApp(passwordHash); + queryRaw.mockResolvedValueOnce([ + { + ...buildEmployee(passwordHash), + password_reset_failed_count: 4, + password_reset_failed_first_at: new Date(), + password_reset_last_requested_at: new Date(), + }, + ]); + + const response = await app.inject({ + method: "POST", + url: "/api/auth/forgot-password", + headers: { "content-type": "application/json" }, + payload: { email: "employee@hyinda.test" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual({}); + expect(passwordResetCodeCreate).not.toHaveBeenCalled(); + expect(employeeUpdate).not.toHaveBeenCalled(); + + await app.close(); + }); + + it("invalidates any active password reset code before creating its replacement", async () => { + const passwordHash = await hashPassword("AdminPassword1234"); + const randomSpy = vi.spyOn(crypto, "randomInt").mockReturnValue(111111); + const { app, passwordResetCodeCreate, passwordResetCodeUpdateMany } = await buildApp(passwordHash); + + const response = await app.inject({ + method: "POST", + url: "/api/auth/forgot-password", + headers: { "content-type": "application/json" }, + payload: { email: "employee@hyinda.test" }, + }); + + expect(response.statusCode).toBe(200); + expect(passwordResetCodeCreate).toHaveBeenCalledTimes(1); + expect(passwordResetCodeCreate.mock.calls[0][0].data.code_hash).toBe( + hashPasswordResetCode("111111"), + ); + expect(passwordResetCodeUpdateMany).toHaveBeenCalledTimes(1); + expect(passwordResetCodeUpdateMany.mock.invocationCallOrder[0]).toBeLessThan( + passwordResetCodeCreate.mock.invocationCallOrder[0], + ); + + randomSpy.mockRestore(); + await app.close(); + }); + + it("locks password recovery on the fifth failed code without rolling back the counters", async () => { + const passwordHash = await hashPassword("AdminPassword1234"); + const { + app, + queryRaw, + employeeUpdate, + passwordResetCodeFindFirst, + passwordResetCodeUpdateMany, + auditCreate, + } = await buildApp(passwordHash); + queryRaw.mockResolvedValueOnce([ + { + ...buildEmployee(passwordHash), + password_reset_failed_count: 4, + password_reset_failed_first_at: new Date(), + }, + ]); + passwordResetCodeFindFirst.mockResolvedValueOnce({ + id: "password-reset-code-1", + code_hash: hashPasswordResetCode("654321"), + attempts_count: 4, + }); + + const response = await app.inject({ + method: "POST", + url: "/api/auth/reset-password", + headers: { "content-type": "application/json" }, + payload: { + email: "employee@hyinda.test", + code: "000000", + newPassword: "ReplacementPassword1234", + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error.details).toEqual({ code: "invalid_or_expired" }); + expect(employeeUpdate).toHaveBeenCalledWith({ + where: { id: EMPLOYEE_ID }, + data: expect.objectContaining({ + password_reset_failed_count: 5, + password_reset_locked_until: expect.any(Date), + }), + }); + expect(passwordResetCodeUpdateMany).toHaveBeenCalledWith({ + where: { + id: "password-reset-code-1", + used_at: null, + invalidated_at: null, + }, + data: { + attempts_count: { increment: 1 }, + invalidated_at: expect.any(Date), + }, + }); + expect(auditCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ action: "AUTH_PASSWORD_RESET_LOCKED" }), + }); + + await app.close(); + }); + + it("does not count a failure when no active password-reset challenge exists", async () => { + const passwordHash = await hashPassword("AdminPassword1234"); + const { app, employeeUpdate, passwordResetCodeUpdateMany, auditCreate } = await buildApp(passwordHash); + + const response = await app.inject({ + method: "POST", + url: "/api/auth/reset-password", + headers: { "content-type": "application/json" }, + payload: { + email: "employee@hyinda.test", + code: "000000", + newPassword: "ReplacementPassword1234", + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error.details).toEqual({ code: "invalid_or_expired" }); + expect(employeeUpdate).not.toHaveBeenCalled(); + expect(passwordResetCodeUpdateMany).not.toHaveBeenCalled(); + expect(auditCreate).not.toHaveBeenCalled(); + + await app.close(); + }); + + it("returns the same reset error for missing, inactive, locked, and wrong-code accounts", async () => { + const passwordHash = await hashPassword("AdminPassword1234"); + const { app, queryRaw, passwordResetCodeFindFirst } = await buildApp(passwordHash); + queryRaw + .mockReset() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ ...buildEmployee(passwordHash), status: "disabled" }]) + .mockResolvedValueOnce([ + { + ...buildEmployee(passwordHash), + password_reset_locked_until: new Date(Date.now() + 60_000), + }, + ]) + .mockResolvedValueOnce([buildEmployee(passwordHash)]); + passwordResetCodeFindFirst.mockResolvedValueOnce({ + id: "password-reset-code-1", + code_hash: hashPasswordResetCode("654321"), + attempts_count: 0, + }); + + const errors = []; + for (const email of [ + "missing@hyinda.test", + "inactive@hyinda.test", + "locked@hyinda.test", + "active@hyinda.test", + ]) { + const response = await app.inject({ + method: "POST", + url: "/api/auth/reset-password", + headers: { "content-type": "application/json" }, + payload: { + email, + code: "000000", + newPassword: "ReplacementPassword1234", + }, + }); + expect(response.statusCode).toBe(400); + errors.push(response.json().error); + } + + for (const error of errors) { + expect(error).toEqual(errors[0]); + } + + await app.close(); + }); + + it("resets the password once and revokes every challenge and refresh session", async () => { + const passwordHash = await hashPassword("AdminPassword1234"); + const { + app, + employeeUpdate, + authSessionUpdateMany, + emailLoginCodeUpdateMany, + passwordResetCodeFindFirst, + passwordResetCodeUpdateMany, + auditCreate, + } = await buildApp(passwordHash); + passwordResetCodeFindFirst.mockResolvedValueOnce({ + id: "password-reset-code-1", + code_hash: hashPasswordResetCode("654321"), + attempts_count: 0, + }); + + const response = await app.inject({ + method: "POST", + url: "/api/auth/reset-password", + headers: { "content-type": "application/json" }, + payload: { + email: "employee@hyinda.test", + code: "654321", + newPassword: "ReplacementPassword1234", + }, + }); + + expect(response.statusCode).toBe(200); + const passwordUpdate = employeeUpdate.mock.calls.find( + ([argument]) => typeof argument.data.password_hash === "string", + )[0]; + expect(await verifyPassword("ReplacementPassword1234", passwordUpdate.data.password_hash)).toBe(true); + expect(passwordUpdate.data).toEqual( + expect.objectContaining({ + token_version: { increment: 1 }, + password_reset_failed_count: 0, + password_reset_request_count: 0, + email_login_code_failed_count: 0, + }), + ); + expect(authSessionUpdateMany).toHaveBeenCalledWith({ + where: { employee_id: EMPLOYEE_ID, revoked_at: null }, + data: { revoked_at: expect.any(Date), last_seen_at: expect.any(Date) }, + }); + expect(passwordResetCodeUpdateMany).toHaveBeenCalledWith({ + where: { employee_id: EMPLOYEE_ID, invalidated_at: null }, + data: { invalidated_at: expect.any(Date) }, + }); + expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({ + where: { employee_id: EMPLOYEE_ID, used_at: null }, + data: { used_at: expect.any(Date) }, + }); + expect(auditCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ action: "AUTH_PASSWORD_RESET_SUCCEEDED" }), + }); + + await app.close(); + }); + + it("allows only one concurrent consumer of the same password reset code", async () => { + const passwordHash = await hashPassword("AdminPassword1234"); + const { + app, + passwordResetCodeFindFirst, + passwordResetCodeUpdateMany, + auditCreate, + } = await buildApp(passwordHash); + passwordResetCodeFindFirst.mockResolvedValue({ + id: "password-reset-code-1", + code_hash: hashPasswordResetCode("654321"), + attempts_count: 0, + }); + let consumed = false; + passwordResetCodeUpdateMany.mockImplementation(async ({ where, data }) => { + if (where.id && data.used_at) { + if (consumed) return { count: 0 }; + consumed = true; + } + return { count: 1 }; + }); + + const responses = await Promise.all( + Array.from({ length: 2 }, () => + app.inject({ + method: "POST", + url: "/api/auth/reset-password", + headers: { "content-type": "application/json" }, + payload: { + email: "employee@hyinda.test", + code: "654321", + newPassword: "ReplacementPassword1234", + }, + }), + ), + ); + + expect(responses.map((response) => response.statusCode).sort()).toEqual([200, 400]); + expect( + auditCreate.mock.calls.filter( + ([argument]) => argument.data.action === "AUTH_PASSWORD_RESET_SUCCEEDED", + ), + ).toHaveLength(1); + + await app.close(); + }); + + it("invalidates outstanding challenges when the user changes the password", async () => { + const passwordHash = await hashPassword("AdminPassword1234"); + const { + app, + employeeUpdateMany, + authSessionUpdateMany, + authSessionCreate, + emailLoginCodeUpdateMany, + passwordResetCodeUpdateMany, + } = await buildApp(passwordHash); + + const response = await app.inject({ + method: "POST", + url: "/api/auth/change-password", + headers: { + "content-type": "application/json", + "x-device-fingerprint": "change-password-device", + }, + payload: { + oldPassword: "AdminPassword1234", + newPassword: "ChangedPassword1234", + }, + }); + + expect(response.statusCode).toBe(200); + expect(employeeUpdateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: EMPLOYEE_ID, + password_hash: passwordHash, + token_version: 3, + }), + }), + ); + expect(authSessionUpdateMany).toHaveBeenCalledWith({ + where: { employee_id: EMPLOYEE_ID, revoked_at: null }, + data: { revoked_at: expect.any(Date), last_seen_at: expect.any(Date) }, + }); + expect(passwordResetCodeUpdateMany).toHaveBeenCalledWith({ + where: { employee_id: EMPLOYEE_ID, invalidated_at: null }, + data: { invalidated_at: expect.any(Date) }, + }); + expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({ + where: { employee_id: EMPLOYEE_ID, used_at: null }, + data: { used_at: expect.any(Date) }, + }); + expect(authSessionCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ device_fingerprint: "change-password-device" }), + }); + + await app.close(); + }); }); diff --git a/server/tests/unit/routes/users.write.test.js b/server/tests/unit/routes/users.write.test.js index 94c7d090..5e48e7b2 100644 --- a/server/tests/unit/routes/users.write.test.js +++ b/server/tests/unit/routes/users.write.test.js @@ -58,6 +58,9 @@ async function buildApp({ const employeeRoleCreateMany = vi.fn().mockResolvedValue({ count: 1 }); const employeeRoleFindMany = vi.fn().mockResolvedValue([{ role_code: "employee" }]); const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" }); + const authSessionUpdateMany = vi.fn().mockResolvedValue({ count: 0 }); + const passwordResetCodeUpdateMany = vi.fn().mockResolvedValue({ count: 0 }); + const emailLoginCodeUpdateMany = vi.fn().mockResolvedValue({ count: 0 }); const tx = { department: { @@ -75,7 +78,13 @@ async function buildApp({ create: auditCreate, }, authSession: { - updateMany: vi.fn().mockResolvedValue({ count: 0 }), + updateMany: authSessionUpdateMany, + }, + passwordResetCode: { + updateMany: passwordResetCodeUpdateMany, + }, + emailLoginCode: { + updateMany: emailLoginCodeUpdateMany, }, }; @@ -134,6 +143,10 @@ async function buildApp({ employeeUpdate, employeeRoleCreateMany, employeeRoleFindMany, + auditCreate, + authSessionUpdateMany, + passwordResetCodeUpdateMany, + emailLoginCodeUpdateMany, }; } @@ -147,7 +160,7 @@ describe("usersRoutes write flows", () => { payload: { name: "张三", phone: "13800000001", - email: "zhangsan@example.com", + email: "ZhangSan@Example.COM", roles: ["employee"], }, }); @@ -155,11 +168,10 @@ describe("usersRoutes write flows", () => { expect(response.statusCode).toBe(200); expect(employeeCreate).toHaveBeenCalledWith( expect.objectContaining({ - data: expect.not.objectContaining({ - employee_no: expect.anything(), - }), + data: expect.objectContaining({ email: "zhangsan@example.com" }), }), ); + expect(employeeCreate.mock.calls[0][0].data).not.toHaveProperty("employee_no"); expect(employeeRoleCreateMany).toHaveBeenCalledWith( expect.objectContaining({ data: [ @@ -210,4 +222,54 @@ describe("usersRoutes write flows", () => { await app.close(); }); + + it("revokes sessions and outstanding challenges during an administrator password reset", async () => { + const { + app, + employeeUpdate, + authSessionUpdateMany, + passwordResetCodeUpdateMany, + emailLoginCodeUpdateMany, + auditCreate, + } = await buildApp(); + + const response = await app.inject({ + method: "POST", + url: `/api/users/${USER_ID}/reset-password`, + payload: { newPassword: "AdministratorReset1234" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual({ + id: USER_ID, + oneTimePassword: "AdministratorReset1234", + }); + expect(employeeUpdate).toHaveBeenCalledWith({ + where: { id: USER_ID }, + data: expect.objectContaining({ + must_change_password: true, + token_version: { increment: 1 }, + email_login_code_failed_count: 0, + password_reset_failed_count: 0, + password_reset_request_count: 0, + }), + }); + expect(authSessionUpdateMany).toHaveBeenCalledWith({ + where: { employee_id: USER_ID, revoked_at: null }, + data: { revoked_at: expect.any(Date), last_seen_at: expect.any(Date) }, + }); + expect(passwordResetCodeUpdateMany).toHaveBeenCalledWith({ + where: { employee_id: USER_ID, invalidated_at: null }, + data: { invalidated_at: expect.any(Date) }, + }); + expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({ + where: { employee_id: USER_ID, used_at: null }, + data: { used_at: expect.any(Date) }, + }); + expect(auditCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ action: "SYSTEM_USER_PASSWORD_RESET" }), + }); + + await app.close(); + }); }); diff --git a/server/tests/unit/scripts/auth-postgres-concurrency.test.js b/server/tests/unit/scripts/auth-postgres-concurrency.test.js new file mode 100644 index 00000000..1eaf5b86 --- /dev/null +++ b/server/tests/unit/scripts/auth-postgres-concurrency.test.js @@ -0,0 +1,200 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const { + AuthConcurrencyCheckError, + validateAuthConcurrencyEnvironment, + validateAuthRegressionEnvironment, +} = require("../../../src/scripts/check-auth-postgres-concurrency"); + +const REPO_ROOT = path.resolve(__dirname, "../../../.."); + +function validEnv() { + return { + RUN_AUTH_POSTGRES_CONCURRENCY: "1", + RUN_AUTH_SECURITY_REGRESSION: "1", + NODE_ENV: "test", + RATE_LIMIT_ENABLED: "false", + DEV_SHOW_PASSWORD_RESET_CODE: "false", + TRUST_PROXY_HOPS: "0", + EMAIL_ENABLED: "false", + SMTP_HOST: "", + SMTP_USER: "", + SMTP_PASS: "", + IM_NOTIFIER_ENABLED: "false", + WECOM_ENABLED: "false", + WECOM_AGENT_SECRET: "", + OIDC_ENABLED: "false", + OIDC_CLIENT_SECRET: "", + DATABASE_URL: + "postgresql://test-user:test-password@127.0.0.1:5432/hyb_oa_auth_test?schema=public", + AUTH_SECURITY_DATABASE_CONFIRM: "hyb_oa_auth_test", + BASE_URL: "http://127.0.0.1:3456", + AUTH_CODE_PEPPER: "test-auth-code-pepper-with-at-least-32-bytes", + JWT_SECRET: "independent-test-jwt-secret", + FIELD_ENCRYPTION_KEY: "test-field-encryption-key", + AUTH_SECURITY_EMPLOYEE_ID: "11111111-1111-4111-8111-111111111111", + AUTH_SECURITY_EMPLOYEE_EMAIL: "synthetic@hyinda.test", + AUTH_SECURITY_CURRENT_PASSWORD: "SyntheticPassword1234", + }; +} + +function expectSafeFailure(env, expectedCode) { + try { + validateAuthConcurrencyEnvironment(env); + throw new Error("expected validation to fail"); + } catch (error) { + expect(error).toBeInstanceOf(AuthConcurrencyCheckError); + expect(error.safeCode).toBe(expectedCode); + expect(error.message).toBe(expectedCode); + } +} + +describe("auth PostgreSQL concurrency guard", () => { + it("accepts only an explicitly confirmed isolated loopback target", () => { + expect(validateAuthConcurrencyEnvironment(validEnv())).toEqual( + expect.objectContaining({ + baseUrl: "http://127.0.0.1:3456", + employeeId: "11111111-1111-4111-8111-111111111111", + }), + ); + }); + + it("does not run without the explicit opt-in", () => { + expectSafeFailure( + { ...validEnv(), RUN_AUTH_POSTGRES_CONCURRENCY: "0" }, + "explicit_opt_in_required", + ); + }); + + it("validates the base regression target before employee-scoped checks", () => { + const env = validEnv(); + delete env.AUTH_SECURITY_EMPLOYEE_ID; + delete env.AUTH_SECURITY_EMPLOYEE_EMAIL; + delete env.AUTH_SECURITY_CURRENT_PASSWORD; + + expect(validateAuthRegressionEnvironment(env)).toEqual( + expect.objectContaining({ baseUrl: "http://127.0.0.1:3456" }), + ); + expectSafeFailure( + { ...env, RUN_AUTH_SECURITY_REGRESSION: "0" }, + "regression_opt_in_required", + ); + }); + + it("refuses production and enabled rate limiting", () => { + expectSafeFailure({ ...validEnv(), NODE_ENV: "production" }, "test_environment_required"); + expectSafeFailure( + { ...validEnv(), RATE_LIMIT_ENABLED: "true" }, + "rate_limit_must_be_disabled", + ); + }); + + it("refuses inherited proxy and external notification providers", () => { + expectSafeFailure( + { ...validEnv(), TRUST_PROXY_HOPS: "1" }, + "trust_proxy_must_be_disabled", + ); + expectSafeFailure( + { ...validEnv(), EMAIL_ENABLED: "true" }, + "email_provider_must_be_disabled", + ); + expectSafeFailure( + { ...validEnv(), SMTP_HOST: "smtp.example.test" }, + "smtp_host_forbidden", + ); + expectSafeFailure( + { ...validEnv(), IM_NOTIFIER_ENABLED: "true" }, + "im_notifier_must_be_disabled", + ); + expectSafeFailure( + { ...validEnv(), OIDC_ENABLED: "true" }, + "oidc_must_be_disabled", + ); + }); + + it("refuses remote PostgreSQL authority and query-host overrides", () => { + expectSafeFailure( + { + ...validEnv(), + DATABASE_URL: + "postgresql://test-user:test-password@db.internal:5432/hyb_oa_auth_test", + }, + "database_host_not_loopback", + ); + expectSafeFailure( + { + ...validEnv(), + DATABASE_URL: + "postgresql://test-user:test-password@127.0.0.1:5432/hyb_oa_auth_test?host=db.internal", + }, + "database_host_not_loopback", + ); + }); + + it("refuses non-isolated or unconfirmed database names", () => { + expectSafeFailure( + { + ...validEnv(), + DATABASE_URL: "postgresql://test-user:test-password@127.0.0.1:5432/hyb_oa_dev", + AUTH_SECURITY_DATABASE_CONFIRM: "hyb_oa_dev", + }, + "database_name_not_isolated", + ); + expectSafeFailure( + { ...validEnv(), AUTH_SECURITY_DATABASE_CONFIRM: "hyb_oa_auth_other" }, + "database_confirmation_mismatch", + ); + }); + + it("refuses a remote API or enabled code logging", () => { + expectSafeFailure( + { ...validEnv(), BASE_URL: "https://oa.example.test" }, + "base_url_not_loopback", + ); + expectSafeFailure( + { ...validEnv(), DEV_SHOW_PASSWORD_RESET_CODE: "true" }, + "code_logging_forbidden", + ); + }); + + it("keeps the HTTP regression fail-closed and redacted", () => { + const source = fs.readFileSync( + path.join(REPO_ROOT, "server/scripts/regression-auth-security.sh"), + "utf8", + ); + const guardIndex = source.indexOf("validateAuthRegressionEnvironment"); + const firstApiWriteIndex = source.indexOf("$BASE_URL/api/auth/login"); + + expect(guardIndex).toBeGreaterThan(-1); + expect(firstApiWriteIndex).toBeGreaterThan(guardIndex); + expect(source).not.toMatch(/cat\s+[^\n]*body/i); + expect(source).toContain("[AUTH-REGRESSION][PARTIAL] BASE_ONLY"); + expect(source).toContain("[AUTH-REGRESSION][PASS] FULL"); + expect(source).toContain("invalid_concurrency_mode"); + }); + + it("keeps auth-security-local isolated and cleanup failures authoritative", () => { + const source = fs.readFileSync( + path.join(REPO_ROOT, "scripts/qa/run-web-gates.sh"), + "utf8", + ); + const dropFunction = source.slice( + source.indexOf("qa_dropdb()"), + source.indexOf("qa_cleanup_server()"), + ); + + expect(source).toContain("hyb-oa-web-gates-server-${QA_RUN_ID}.pid"); + expect(source).toContain('QA_SERVER_STARTED=0'); + expect(source).toContain('QA_SERVER_OWNED=0'); + expect(source).toContain('export TRUST_PROXY_HOPS=0'); + expect(source).toContain('export EMAIL_ENABLED=false'); + expect(source).toContain('export OIDC_ENABLED=false'); + expect(source).toContain('unset JWT_SECRET AUTH_CODE_PEPPER FIELD_ENCRYPTION_KEY'); + expect(source).toContain('QA_SERVER_ENTRY="src/scripts/start-auth-security-test-server.js"'); + expect(source).toContain('RUN_AUTH_POSTGRES_CONCURRENCY=1 ./scripts/regression-auth-security.sh'); + expect(source).toContain('exit "$cleanup_status"'); + expect(dropFunction).not.toContain("|| true"); + expect(dropFunction).toContain("return 1"); + }); +});