127 lines
7.4 KiB
Markdown
127 lines
7.4 KiB
Markdown
# Implementation Plan: Authentication Hardening
|
|||
|
|
|
||
|
|
**Branch**: `013-auth-hardening` | **Date**: 2026-09-07 | **Spec**: [spec.md](./spec.md)
|
||
|
|
|
||
|
|
**Input**: Feature specification from `specs/013-auth-hardening/spec.md`
|
||
|
|
|
||
|
|
## Summary
|
||
|
|
|
||
|
|
Adds `POST /auth/password-reset/request` and `POST /auth/password-reset/consume` to
|
||
|
|
`identity/auth` (the module that already owns login/logout/self-identity mechanics), backed by
|
||
|
|
a Redis-stored, single-use reset token — the "delivery" step logs the token server-side rather
|
||
|
|
than emailing it. Adds a shared password-strength validator used by both the reset-consume
|
||
|
|
endpoint and 010's own `POST /admin/users`. Adds a pre-credential-check rate limit to
|
||
|
|
`POST /auth/login`, reusing the existing `checkRateLimit` helper 002's own inbound trust
|
||
|
|
boundary already established.
|
||
|
|
|
||
|
|
## Technical Context
|
||
|
|
|
||
|
|
**Language/Version**: TypeScript 5.4 / Node.js 20+ (unchanged).
|
||
|
|
|
||
|
|
**Primary Dependencies**: None new — reuses `crypto` (Node built-in, for token generation and
|
||
|
|
hashing), the existing `ioredis` client, and `zod`.
|
||
|
|
|
||
|
|
**Storage**: No schema change. Reset tokens live entirely in Redis (never in Postgres) — two
|
||
|
|
keys per active token, mirroring the existing revocation-denylist's own Redis-key-with-TTL shape:
|
||
|
|
`password-reset:token:<sha256(token)>` → `userId`, and `password-reset:user:<userId>` →
|
||
|
|
`sha256(token)`, both with the same TTL (the reset token's own lifetime). The second key is what
|
||
|
|
lets issuing a new token invalidate the previous one (FR-002) without a database table.
|
||
|
|
|
||
|
|
**Testing**: Vitest — unit tests for the password-strength validator and the rate-limit's own
|
||
|
|
pre-credential-check ordering; integration tests against real Postgres/Redis for the full
|
||
|
|
request → (read the token from the stub's log output) → consume → login-with-new-password flow,
|
||
|
|
the identical-response-regardless-of-existing-account behavior, and the login rate limit
|
||
|
|
actually rejecting the N+1th attempt while a different account's login proceeds normally.
|
||
|
|
|
||
|
|
**Target Platform**: Same Fastify modular monolith. Modifies `identity/auth` (new routes,
|
||
|
|
service methods, the shared password-strength validator) and `identity/agents` (existing
|
||
|
|
`POST /admin/users` now calls the shared validator instead of accepting any password
|
||
|
|
unchecked).
|
||
|
|
|
||
|
|
**Project Type**: Backend service — single project.
|
||
|
|
|
||
|
|
**Performance Goals**: The login rate-limit check is one Redis `INCR` (already how
|
||
|
|
`checkRateLimit` works) — no added database round trip on the login hot path, consistent with
|
||
|
|
010's own performance goal for `fastify.authenticate`.
|
||
|
|
|
||
|
|
**Constraints**: FR-001/SC-001 — reset-request must respond identically regardless of account
|
||
|
|
existence, including timing-shape (the same pattern 010's login already established: do the
|
||
|
|
same amount of work either way). FR-007 — the rate-limit check MUST run before
|
||
|
|
`bcrypt.compare`, not after i.e. before any password-verification cost is paid, both for
|
||
|
|
FR-007's own ordering requirement and so a rate-limited attacker gains no timing signal from a
|
||
|
|
skipped bcrypt call.
|
||
|
|
|
||
|
|
**Scale/Scope**: Two new routes, one new shared validator, one new env-configured rate-limit
|
||
|
|
policy, one modified existing endpoint (`POST /admin/users`). No new module, no schema
|
||
|
|
migration, no new module dependencies. Explicitly excludes: MFA, real email delivery, IP-based
|
||
|
|
rate limiting, password complexity rules beyond minimum length (spec.md Assumptions).
|
||
|
|
|
||
|
|
## Constitution Check
|
||
|
|
|
||
|
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||
|
|
|
||
|
|
| Principle / Section | Check | Result |
|
||
|
|
|---|---|---|
|
||
|
|
| I. SaaS Is the Sole Identity & Access Authority | Same carve-out as 010 — this hardens SupportHub's own staff authentication, never touching SaaS-delegated customer identity. | PASS |
|
||
|
|
| II. Configuration Over Hardcoding | Password minimum length and the login rate-limit's max-attempts/window are both new env-configured values (`PASSWORD_MIN_LENGTH`, `LOGIN_RATE_LIMIT_MAX_ATTEMPTS`, `LOGIN_RATE_LIMIT_WINDOW_SECONDS`), never hardcoded magic numbers — matches spec.md's own Assumptions and the roadmap's "never hardcode a placeholder value and ship it as final." | PASS |
|
||
|
|
| III. Layered Architecture With Enforced Module Boundaries | Reset endpoints live in `identity/auth` (owns auth mechanics); the shared password-strength validator is exported from `identity/auth`'s own public `index.ts` for `identity/agents` to consume, the same precedent `hashPassword`/`verifyPassword` themselves already set. | PASS |
|
||
|
|
| IV. AI Recommends, Deterministic Policy Decides | Not applicable. | PASS — N/A |
|
||
|
|
| V. Evidence-Based Verification | Not applicable. | PASS — N/A |
|
||
|
|
| VI. Durable Audit & History | Not applicable — no new audit-relevant mutable domain state (a password hash change isn't itself an audited business event in this codebase's existing model). | PASS — N/A |
|
||
|
|
| VII. Concurrency-Safe, Durable Job Handling | Reset-token issuance/consumption is a single Redis operation per step, no shared in-memory state; two concurrent consume attempts for the same token race safely (Redis `GET`+`DEL` — the loser sees the key already gone and is rejected, not a partial/double-apply). | PASS |
|
||
|
|
| VIII. Problem and Ticket Are Separate, Related Entities | Not applicable. | PASS — N/A |
|
||
|
|
| Technology & Platform Constraints | No new dependencies or infrastructure — email delivery is explicitly stubbed (spec.md Assumptions, user decision), not a real provider integration. | PASS |
|
||
|
|
|
||
|
|
No violations requiring Complexity Tracking justification.
|
||
|
|
|
||
|
|
## Project Structure
|
||
|
|
|
||
|
|
### Documentation (this feature)
|
||
|
|
|
||
|
|
```text
|
||
|
|
specs/013-auth-hardening/
|
||
|
|
├── plan.md
|
||
|
|
├── research.md
|
||
|
|
├── data-model.md
|
||
|
|
├── quickstart.md
|
||
|
|
├── contracts/
|
||
|
|
└── tasks.md
|
||
|
|
```
|
||
|
|
|
||
|
|
### Source Code (repository root)
|
||
|
|
|
||
|
|
```text
|
||
|
|
supporthub-api/
|
||
|
|
├── src/
|
||
|
|
│ ├── config/
|
||
|
|
│ │ └── auth.ts # MODIFIED — passwordMinLength, loginRateLimit config
|
||
|
|
│ └── modules/
|
||
|
|
│ └── identity/
|
||
|
|
│ ├── auth/ # MODIFIED
|
||
|
|
│ │ ├── mapper/
|
||
|
|
│ │ │ └── password-policy.ts # NEW — shared validatePasswordStrength
|
||
|
|
│ │ ├── mapper/
|
||
|
|
│ │ │ └── reset-token.ts # NEW — generate/hash reset tokens
|
||
|
|
│ │ ├── repository/
|
||
|
|
│ │ │ └── reset-token.repository.ts # NEW — the two-Redis-key shape
|
||
|
|
│ │ ├── service/ # MODIFIED — requestPasswordReset, resetPassword,
|
||
|
|
│ │ │ login's new pre-check rate-limit call
|
||
|
|
│ │ ├── controller/ routes/ # MODIFIED — the two new routes
|
||
|
|
│ │ └── schema/ # MODIFIED — request/consume body schemas
|
||
|
|
│ └── agents/
|
||
|
|
│ └── service/
|
||
|
|
│ └── users.service.ts # MODIFIED — calls the shared validator
|
||
|
|
└── tests/
|
||
|
|
├── unit/identity/ # password-policy validator, rate-limit ordering
|
||
|
|
└── integration/ # full reset flow, identical-response check,
|
||
|
|
login rate-limit behavior
|
||
|
|
```
|
||
|
|
|
||
|
|
**Structure Decision**: Single project, no new module. Everything lives in `identity/auth`
|
||
|
|
(already owns login/logout/self-identity) except the one-line call site change in
|
||
|
|
`identity/agents/service/users.service.ts`.
|
||
|
|
|
||
|
|
## Complexity Tracking
|
||
|
|
|
||
|
|
*No constitution violations — table intentionally omitted.*
|