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

这个提交包含在:
2026-07-17 14:24:58 +08:00
父节点 bbb5c89825
当前提交 6ddedf1e0e
共修改 31 个文件,包含 3801 行新增和 419 行删除
@@ -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);
```
@@ -352,3 +352,93 @@ node deploy/trialrun/check-source-provenance.mjs
BUILD_SHA=<verified-full-sha> VITE_BUILD_SHA=<matching-short-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
```