Files
mercadodevida/legacy/specs-old/F-005-identity/DESIGN.md
2026-08-17 22:23:10 +02:00

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.
  • 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

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.