114 lines
5.3 KiB
Markdown
114 lines
5.3 KiB
Markdown
# 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.
|