5.3 KiB
5.3 KiB
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
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.examplesets 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
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-Afterseconds.
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)replacescreatePoolFromEnv(config owns env).config.ts: adds optionalCOOKIE_SECURE(default true).buildApp(deps): new optionalpool+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.