Files
support_backend/specs/013-auth-hardening/plan.md
T

127 lines
7.4 KiB
Markdown
Raw Normal View History

# 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.*