feat(identity): F-005 register/login/logout with argon2 sessions and rate limiting

- Hexagonal identity module: domain ports, use cases, argon2id hasher, pg repos
- Migration 002_identity: identity_users + identity_sessions (token hash only)
- Opaque 512-bit session tokens; DB stores SHA-256 hash; 7-day TTL in SQL
- Cookie HttpOnly + Secure (COOKIE_SECURE, default true) + SameSite=Lax
- LoginRateLimiter: 10 failures -> 429 + Retry-After, 15-min cooldown
- Anti-enumeration: identical generic 401 + dummy-hash timing equalization
- buildApp gains optional pool/cookieSecure; foundation-only app preserved
- 47 unit + 14 integration tests; live smoke covers all acceptance criteria
This commit is contained in:
rikrdo
2026-08-14 22:58:32 +02:00
parent 4851692031
commit 75293f39bc
46 changed files with 1734 additions and 54 deletions

View File

@@ -23,7 +23,8 @@ npm run lint:boundaries # module boundary check
Startup is fail-fast: `src/infrastructure/config` parses env once and refuses to boot
on missing/invalid required vars. `DATABASE_URL` is required; `PORT`, `HOST`,
`LOG_LEVEL`, `NODE_ENV`, `REDIS_URL` are optional with defaults. Errors name variable
`LOG_LEVEL`, `NODE_ENV`, `REDIS_URL` are optional with defaults. `COOKIE_SECURE`
defaults to `true` (set `false` only for local http dev). Errors name variable
NAMES only, never values.
Feature flags: `FLAG_<NAME>=true|false` env vars seed the flag store at boot. Unknown
@@ -39,8 +40,34 @@ is separate from deployment (no redeploy). Copy `.env.example` to `.env` to star
Codes: `NOT_FOUND`, `VALIDATION_ERROR`, `BAD_REQUEST`/Fastify 4xx codes, `INTERNAL_ERROR`.
5xx messages are always generic; stack traces stay in server logs only.
- Input validation is explicit per route: `parseJson(schema, body)` (zod) in the handler.
- Auth codes: `INVALID_CREDENTIALS` (401), `EMAIL_ALREADY_REGISTERED` (409),
`TOO_MANY_ATTEMPTS` (429, with `Retry-After` header).
- Log level via `LOG_LEVEL` env var (default `info`); logs are JSON only.
## Authentication (identity module)
The server is the only authority for identity; the frontend is never trusted with
session or credential state.
| Route | Result |
| ------------------- | --------------------------------------------------- |
| POST /auth/register | `201` + `{ id, email, createdAt }` |
| POST /auth/login | `200` + `{ id, email }` + `Set-Cookie: mdv_session` |
| POST /auth/logout | `204`, cookie cleared, session revoked (idempotent) |
- Passwords: argon2id (OWASP parameters). Only the PHC hash is stored, never
plaintext or anything reversible.
- Sessions: opaque 512-bit token in the cookie; the DB stores only its SHA-256
hash (`identity_sessions.token_hash`). TTL 7 days; logout revokes server-side.
- Cookie: `HttpOnly`, `Secure` (`COOKIE_SECURE`, default true), `SameSite=Lax`,
`Path=/`, `Max-Age=604800`.
- Login failures: identical generic 401 for unknown email and wrong password (no
enumeration; timing equalized via dummy hash). After 10 consecutive failures per
email, further attempts get `429` with `Retry-After` for 15 minutes. The limiter
is in-memory per instance behind a `LoginRateLimiter` interface (Redis-backed
swap later without touching use cases).
- Identity routes are wired only when the app is built with a DB pool.
## Database (local dev)
```bash
@@ -69,10 +96,12 @@ npm run docker:down # stop services (add -v to wipe volumes)
```text
src/
├── app/ # composition root (only place that wires modules)
├── infrastructure/ # http server entrypoint (later: db, redis, providers)
├── infrastructure/ # http server, db pool, config, logging
├── modules/ # business modules, one folder each
── health/ # exemplar module: public API only via index.ts
└── shared/ # cross-cutting helpers (error envelope)
── health/ # exemplar module: public API only via index.ts
│ ├── flags/ # feature flags (unknown default OFF, runtime flip)
│ └── identity/ # register/login/logout, argon2, sessions, rate limit
└── shared/ # cross-cutting helpers (error envelope, input parsing)
```
## Module rules