feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View File

@@ -0,0 +1,113 @@
# DESIGN — F-005 Identity: register, login, sessions
## Module layout (hexagonal, screaming)
```
src/modules/identity/
index.ts # public API only: registerIdentityRoutes + types
domain/
user.ts # User, NewUser types; email normalization
session.ts # Session type, token TTL constants
errors.ts # InvalidCredentials, EmailAlreadyRegistered, RateLimited
ports.ts # PasswordHasher, UserRepository, SessionRepository
application/
register-user.ts # RegisterUser use case
login.ts # Login use case (rate limit + timing equalization)
logout.ts # Logout use case (revoke by token hash)
login-rate-limiter.ts # LoginRateLimiter interface + InMemoryLoginRateLimiter
infrastructure/
argon2-password-hasher.ts # argon2id, OWASP params
pg-user-repository.ts # identity_users CRUD (pg parameterized)
pg-session-repository.ts # identity_sessions create/revoke-by-token-hash
session-token.ts # random token + sha256 hash helpers
api/
identity.routes.ts # POST /auth/register | /auth/login | /auth/logout
tests/
login-rate-limiter.test.ts # unit
session-token.test.ts # unit
```
Rules respected: domain imports nothing from infra/frameworks; application depends on
domain ports only; infrastructure implements ports; api is a thin adapter using
`parseJson` (F-003) and maps domain errors to AppError.
## Data model — migration 002_identity
```sql
identity_users(
id uuid PK DEFAULT gen_random_uuid(),
email citext NOT NULL UNIQUE,
password_hash text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
identity_sessions(
id uuid PK DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES identity_users(id) ON DELETE CASCADE,
token_hash text NOT NULL UNIQUE, -- sha256 of the opaque cookie token
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
revoked_at timestamptz
) + index on user_id
```
- citext for case-insensitive unique email (extension from baseline).
- Only the token HASH is stored: a DB leak does not yield usable sessions.
- Down drops both tables (reverse FK order).
## Password hashing
- argon2id, memoryCost 19456 KiB, timeCost 2, parallelism 1 (OWASP 2024 baseline).
- Output PHC string (`$argon2id$...`) stored as-is; verify via argon2.verify.
- Timing equalization: unknown email still runs one verify against a module-level
dummy hash so 401 timing does not enumerate users.
## Sessions and cookie
- Token: `crypto.randomBytes(64).toString('base64url')` (opaque, 512 bits).
- TTL: 7 days, stamped in `expires_at`; expired/revoked sessions are invalid.
- Cookie `mdv_session`: HttpOnly, SameSite=Lax, Path=/, Secure per config
(`COOKIE_SECURE`, default true; `.env.example` sets false for local http dev),
Max-Age = TTL.
- Logout revokes by token hash (`revoked_at`), always answers 204 and clears the
cookie (idempotent).
## Rate limiting
```ts
interface LoginRateLimiter {
consume(key: string): { allowed: true } | { allowed: false; retryAfterMs: number };
recordFailure(key: string): void;
reset(key: string): void;
}
```
- InMemoryLoginRateLimiter: per normalized email, threshold 10 consecutive failures,
15-minute cooldown once reached, reset on success. Lazy sweep of expired entries.
- In-memory per instance (documented limitation; interface allows Redis-backed swap).
- 429 response includes `Retry-After` seconds.
## Endpoints
| Route | Success | Errors |
|---|---|---|
| POST /auth/register | 201 `{ id, email, createdAt }` | 400 VALIDATION_ERROR, 409 EMAIL_ALREADY_REGISTERED |
| POST /auth/login | 200 `{ id, email }` + Set-Cookie | 401 INVALID_CREDENTIALS (generic), 429 TOO_MANY_ATTEMPTS |
| POST /auth/logout | 204 + clear cookie | — (idempotent) |
Request bodies validated with zod via `parseJson` (F-003 hook): email + password
(min 8, max 128 chars).
## Wiring
- `pool.ts`: `createPool(connectionString)` replaces `createPoolFromEnv` (config owns env).
- `config.ts`: adds optional `COOKIE_SECURE` (default true).
- `buildApp(deps)`: new optional `pool` + `cookieSecure`; when pool present it registers
identity routes. No pool = foundation-only app (keeps F-001..F-004 tests DB-free).
- `server.ts`: creates pool from config, passes into buildApp.
## Test strategy
- Unit (module-scoped): rate limiter thresholds/reset/cooldown; session token
randomness/hashing; domain error mapping.
- Composition (app-scoped itest, real PostgreSQL): register/login/logout happy paths;
argon2 PHC hash in DB + no plaintext; duplicate email 409; identical 401 bodies for
wrong password vs unknown email; cookie attributes; 10 failures -> 429 + Retry-After;
logout revokes (second logout idempotent, session unusable).
## Risks / mitigations
- Native argon2 build: toolchain present; if install fails, switch impl behind the
PasswordHasher port to @node-rs/argon2 (no design change).
- Enumeration: generic 401 + dummy verify; register 409 accepted as standard contract
(login is the enumeration-sensitive path per acceptance).
- In-memory rate limiter bypass via multi-instance: documented; interface ready for Redis.

View File

@@ -0,0 +1,37 @@
# SPEC — F-005 Identity: register, login, sessions
## Problem
Customers need accounts; nothing trusts who calls the API.
## Goal
Registration, login, logout with hashed passwords and secure sessions — server is the
only authority for identity (never trust the frontend).
## Scope IN
- `src/modules/identity` with hexagonal layers: domain / application / infrastructure / api
- Register, login, logout use cases over PostgreSQL (source of truth)
- Argon2id password hashing (OWASP parameters)
- Server-side opaque sessions stored in DB; cookie carries a random token, DB stores only its SHA-256 hash
- Secure session cookie: HttpOnly, Secure, SameSite
- Login rate limiting (10 consecutive failures -> 429 with cooldown) behind an interface
- Migration 002_identity (identity_users, identity_sessions) with down
- Config: COOKIE_SECURE flag (default true)
## Scope OUT
- No MFA, no OAuth providers, no profile editing
- No email verification flow, no password reset
- No distributed rate limiting (in-memory per instance; documented)
- No expired-session sweeper (follow-up)
## Acceptance criteria
1. Given valid credentials When login Then HTTP 200 and secure session cookie set.
2. Given wrong password When login Then HTTP 401 and no user enumeration hint (unknown email returns the identical 401).
3. Passwords stored with argon2, never plaintext or reversible.
4. Given 10 failed logins in a row When next login attempted Then HTTP 429.
5. Session cookie is HttpOnly, Secure and SameSite.
6. `./scripts/verify.sh` green.
## Dependencies added
- argon2 (canonical Argon2id implementation; PHC string output)
- @fastify/cookie (explicit setCookie/clearCookie; official Fastify plugin)
Justification goes to `spec/tech.md`.

View File

@@ -0,0 +1,13 @@
# TASKS — F-005 Identity: register, login, sessions
- [ ] TASK-001 deps: argon2 + @fastify/cookie; justify in spec/tech.md
- [ ] TASK-002 migrations/002_identity.js (identity_users + identity_sessions, reversible)
- [ ] TASK-003 domain: user.ts, session.ts, errors.ts, ports.ts
- [ ] TASK-004 application: register-user.ts, login.ts, logout.ts, login-rate-limiter.ts
- [ ] TASK-005 infrastructure: argon2 hasher, pg repos, session-token
- [ ] TASK-006 api: identity.routes.ts (zod via parseJson, error mapping, cookie)
- [ ] TASK-007 index.ts public API
- [ ] TASK-008 foundation wiring: pool.createPool(connectionString), config COOKIE_SECURE, buildApp(pool, cookieSecure), server.ts pool creation
- [ ] TASK-009 unit tests: rate limiter + session token (module-scoped)
- [ ] TASK-010 integration tests: full flows against real PostgreSQL (app-scoped itest)
- [ ] TASK-011 run lint + boundaries + typecheck + build + test + test:integration + verify.sh

View File

@@ -0,0 +1,25 @@
# TESTS — F-005 Identity: register, login, sessions
## Unit — src/modules/identity/tests/login-rate-limiter.test.ts
1. Allows attempts below threshold; records failures
2. Blocks with retryAfterMs after 10 consecutive failures (AC4 logic)
3. Success resets the counter
4. Cooldown expiry re-allows attempts
## Unit — src/modules/identity/tests/session-token.test.ts
1. Tokens are unique and URL-safe
2. hashSessionToken is deterministic sha256 hex and never equals the raw token
## Integration — src/app/tests/identity.itest.ts (real PostgreSQL, fresh DB per run)
1. register: 201 + body {id, email, createdAt}; DB row stores $argon2id$ hash; plaintext password appears nowhere in the row (AC3)
2. register duplicate email: 409 EMAIL_ALREADY_REGISTERED
3. register invalid payload: 400 VALIDATION_ERROR
4. login valid: 200 + mdv_session cookie with HttpOnly; SameSite=Lax; Secure; Max-Age (AC1, AC5)
5. login wrong password: 401 INVALID_CREDENTIALS; unknown email returns byte-identical error body (AC2)
6. rate limit: 10 failed logins then next attempt -> 429 TOO_MANY_ATTEMPTS + Retry-After header (AC4)
7. logout: 204 + cookie cleared; session row revoked; second logout still 204 (idempotent)
8. buildApp without pool: /auth/login is 404 (foundation-only app preserved)
## QA live
- register/login/logout over HTTP with curl; cookie flags visible in Set-Cookie
- verify.sh green (AC6)