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:
33
work/artifacts/F-005/architect.md
Normal file
33
work/artifacts/F-005/architect.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Architect — F-005 Identity: register, login, sessions
|
||||
|
||||
done -> work/artifacts/F-005/architect.md
|
||||
|
||||
## Deliverables
|
||||
- specs/F-005-identity/SPEC.md, DESIGN.md, TASKS.md, TESTS.md
|
||||
|
||||
## Key decisions
|
||||
1. **Server-side opaque sessions in PostgreSQL**, never JWT/client-trusted tokens:
|
||||
the cookie carries 512 bits of randomness; the DB stores only the SHA-256 hash.
|
||||
A DB leak yields no usable sessions; logout is a real revocation.
|
||||
2. **Hexagonal identity module** (domain/application/infrastructure/api) as the ticket
|
||||
demands. Domain knows no framework; ports live in domain; infra implements them.
|
||||
This module is the template for catalog/inventory/orders later.
|
||||
3. **argon2id with OWASP 2024 params** (m=19456 KiB, t=2, p=1) behind a PasswordHasher
|
||||
port. Timing equalization via dummy-hash verify on unknown email.
|
||||
4. **Rate limiting behind a LoginRateLimiter interface**, in-memory implementation for
|
||||
the single-instance monolith: 10 consecutive failures per email, 15-min cooldown,
|
||||
reset on success, Retry-After on 429. Redis-backed swap later without touching use cases.
|
||||
5. **Cookie policy**: HttpOnly + SameSite=Lax always; Secure via `COOKIE_SECURE`
|
||||
(default true — production-safe; .env.example sets false for local http).
|
||||
6. **Foundation touchpoints kept minimal**: createPool(connectionString), COOKIE_SECURE
|
||||
in config, buildApp gains optional pool dep (foundation-only app preserved when absent).
|
||||
|
||||
## Security posture
|
||||
- No plaintext or reversible passwords anywhere (argon2 PHC strings only).
|
||||
- Generic 401 for both unknown email and wrong password (no enumeration).
|
||||
- Parameterized SQL only; citext unique email enforced in DB (race-safe).
|
||||
- Session expiry + revocation enforced in the DB query, not in app memory.
|
||||
|
||||
## Risks
|
||||
- argon2 native build (toolchain verified present; fallback @node-rs/argon2 behind port).
|
||||
- In-memory limiter is per-instance (documented; acceptable for monolith slice).
|
||||
22
work/artifacts/F-005/documenter.md
Normal file
22
work/artifacts/F-005/documenter.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Documenter — F-005 Identity: register, login, sessions
|
||||
|
||||
done -> work/artifacts/F-005/documenter.md
|
||||
|
||||
## Docs updated
|
||||
- `project/README.md`:
|
||||
- Configuration: `COOKIE_SECURE` documented (default true).
|
||||
- HTTP contract: auth error codes (`INVALID_CREDENTIALS` 401,
|
||||
`EMAIL_ALREADY_REGISTERED` 409, `TOO_MANY_ATTEMPTS` 429 + Retry-After).
|
||||
- New "Authentication (identity module)" section: endpoints table, argon2id,
|
||||
opaque-token sessions (DB stores only SHA-256 hash), cookie flags,
|
||||
anti-enumeration + rate-limit behavior, pool-gated wiring.
|
||||
- Layout: modules list now shows health/flags/identity; infrastructure mentions
|
||||
db pool + config + logging; shared mentions input parsing.
|
||||
- `spec/tech.md`: argon2 + @fastify/cookie justified (F-005 row).
|
||||
- `project/.env.example`: `COOKIE_SECURE=false` with local-dev comment.
|
||||
|
||||
## Specs (source of truth for this ticket)
|
||||
- specs/F-005-identity/SPEC.md, DESIGN.md, TASKS.md, TESTS.md
|
||||
|
||||
## Evidence trail
|
||||
- work/artifacts/F-005/: architect.md, implementer.md, reviewer.json, security.json, qa.json
|
||||
41
work/artifacts/F-005/implementer.md
Normal file
41
work/artifacts/F-005/implementer.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Implementer — F-005 Identity: register, login, sessions
|
||||
|
||||
done -> work/artifacts/F-005/implementer.md
|
||||
|
||||
## Delivered
|
||||
- Migration `migrations/002_identity.js`: `identity_users` + `identity_sessions`
|
||||
(token_hash UNIQUE, FK ON DELETE CASCADE, user_id index). Reversible.
|
||||
- Hexagonal module `src/modules/identity`:
|
||||
- domain: user.ts, session.ts (7-day TTL), errors.ts, ports.ts
|
||||
- application: register-user.ts, login.ts, logout.ts, login-rate-limiter.ts
|
||||
- infrastructure: argon2-password-hasher.ts (argon2id m=19456 t=2 p=1),
|
||||
pg-user-repository.ts, pg-session-repository.ts, session-token.ts
|
||||
- api: identity.routes.ts (zod via parseJson, envelope v2 mapping)
|
||||
- Foundation wiring: `createPool(connectionString)`, `COOKIE_SECURE` in config
|
||||
(default true), `buildApp({ pool, cookieSecure })`, server creates pool from config.
|
||||
|
||||
## Deps added (justified in spec/tech.md)
|
||||
- argon2, @fastify/cookie
|
||||
|
||||
## Evidence
|
||||
- `npm run lint` ✅ (prettier clean after format pass)
|
||||
- `npm run lint:boundaries` ✅ (39 files, R1/R2 OK — module tests stay in-subtree,
|
||||
DB integration tests live in src/app/tests)
|
||||
- `npm run typecheck` / `npm run build` ✅
|
||||
- `npm test`: 46 passed, 14 skipped (integration) ✅
|
||||
- `npm run test:integration`: 14/14 ✅ (register argon2-only storage, dup 409,
|
||||
invalid 400, cookie flags, identical 401 bodies, revocation + idempotent logout,
|
||||
10 failures → 429 + Retry-After, foundation-only app keeps /auth 404)
|
||||
- Live smoke (curl): 201 register / 200 login + `mdv_session …; HttpOnly; Secure;
|
||||
SameSite=Lax; Max-Age=604800` / 401 identical for wrong-password vs unknown-email /
|
||||
204 logout clears cookie / 10×401 → 429 + `retry-after: 900`
|
||||
- `./scripts/verify.sh` ✅
|
||||
|
||||
## Notes / deviations
|
||||
- F-002 migrations.itest baseline rollback test updated: `down` now uses `count: 0`
|
||||
(full revert) since the suite has two migrations; test asserts identity tables AND
|
||||
app_meta are gone. Helper `runMigrations` gained an optional count param.
|
||||
- Rate limiter is in-memory per instance (documented in DESIGN risks; interface ready
|
||||
for Redis swap).
|
||||
- `project/.env` (gitignored) supplies TEST_DATABASE_URL to the `test:integration`
|
||||
script via `node --env-file-if-exists=.env`.
|
||||
37
work/artifacts/F-005/leader-close.json
Normal file
37
work/artifacts/F-005/leader-close.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"feature_id": "F-005",
|
||||
"agent": "leader",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "F-005 closed. First real business module: identity is hexagonal end-to-end, the server is the only authority for who calls the API, and brute force, enumeration and session theft are all handled server-side. All gates APPROVED, verify.sh exit 0.",
|
||||
"gates": {
|
||||
"reviewer": "APPROVED (reviewer.json)",
|
||||
"security": "APPROVED (security.json)",
|
||||
"qa": "APPROVED (qa.json)",
|
||||
"verify_sh": "exit 0"
|
||||
},
|
||||
"deliverables": [
|
||||
"migrations/002_identity.js (identity_users + identity_sessions, reversible, token_hash UNIQUE)",
|
||||
"src/modules/identity hexagonal: domain ports, use cases (register/login/logout), argon2id hasher, pg repos, routes",
|
||||
"Opaque 512-bit session tokens; DB persists only SHA-256 hashes; 7-day TTL enforced in SQL",
|
||||
"LoginRateLimiter interface + in-memory impl: 10 failures -> 429 + Retry-After, 15-min cooldown, reset on success",
|
||||
"Anti-enumeration: identical generic 401 + dummy-hash timing equalization",
|
||||
"buildApp(pool, cookieSecure) wiring; foundation-only app preserved without pool",
|
||||
"47 unit + 14 integration tests; live curl smoke for every acceptance criterion",
|
||||
"README Authentication section, spec/tech.md dependency justification, .env.example COOKIE_SECURE"
|
||||
],
|
||||
"process_notes": [
|
||||
"Review gate caught missing COOKIE_SECURE test coverage; bounced to build, fixed, re-verified — gate earned its keep.",
|
||||
"Adding migration 002 broke the F-002 single-step rollback test by design; updated to full revert (count:0) asserting both module tables and baseline disappear."
|
||||
],
|
||||
"next_feature_hint": "F-006 users/profile/RBAC builds directly on identity_users and the session model -> recommended next",
|
||||
"evidence": [
|
||||
"work/artifacts/F-005/architect.md",
|
||||
"work/artifacts/F-005/implementer.md",
|
||||
"work/artifacts/F-005/reviewer.json",
|
||||
"work/artifacts/F-005/security.json",
|
||||
"work/artifacts/F-005/qa.json",
|
||||
"work/artifacts/F-005/documenter.md",
|
||||
"./scripts/verify.sh exit 0 at close"
|
||||
],
|
||||
"timestamp": "2026-08-14T21:15:00Z"
|
||||
}
|
||||
29
work/artifacts/F-005/qa.json
Normal file
29
work/artifacts/F-005/qa.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"feature_id": "F-005",
|
||||
"agent": "qa",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "Los 6 criterios de aceptación pasan con evidencia doble: suite automatizada (47 unit + 14 integration) y smoke live contra PostgreSQL real. Sin regresiones en F-001..F-004.",
|
||||
"acceptance_criteria": {
|
||||
"AC1_login_200_with_cookie": "PASS - integration 'login with valid credentials -> 200 + secure cookie' + curl live: HTTP 200 + Set-Cookie mdv_session=...; Max-Age=604800; Path=/; HttpOnly; Secure; SameSite=Lax",
|
||||
"AC2_wrong_password_401_no_enumeration": "PASS - integration compara bodies .error idénticos para wrong-password vs unknown-email; curl live confirma 401 INVALID_CREDENTIALS genérico en ambos",
|
||||
"AC3_argon2_never_plaintext": "PASS - integration: fila DB password_hash arranca con $argon2id$ y el plaintext no aparece en ningún valor serializado de la fila",
|
||||
"AC4_rate_limit_429": "PASS - integration: 10 fallos -> 429 TOO_MANY_ATTEMPTS + Retry-After > 0; live: 401×10 y luego 429 con retry-after: 900",
|
||||
"AC5_cookie_flags": "PASS - integration valida HttpOnly + SameSite=Lax + Secure + Max-Age sobre el header Set-Cookie; confirmado en vivo",
|
||||
"AC6_verify_green": "PASS - ./scripts/verify.sh OK tras el cierre de build+gates"
|
||||
},
|
||||
"regression": {
|
||||
"health_endpoint": "PASS (suite)",
|
||||
"error_envelope_v2_404": "PASS (suite) + foundation-only app sin pool mantiene /auth 404 (integration)",
|
||||
"config_fail_fast": "PASS (suite: DATABASE_URL missing, COOKIE_SECURE inválido reporta variable)",
|
||||
"feature_flags": "PASS (suite: unknown OFF, case-insensitive, runtime flip)",
|
||||
"migrations_lifecycle": "PASS (integration: fresh up full schema, second up no-op, full down limpio)"
|
||||
},
|
||||
"evidence": [
|
||||
"npm run lint / lint:boundaries (39 files) / typecheck / build: clean",
|
||||
"npm test: 47 passed | 14 skipped",
|
||||
"npm run test:integration: 14/14 passed",
|
||||
"curl live: register 201, login 200+cookie, wrong/unknown 401 idénticos, logout 204+clear, 429+Retry-After",
|
||||
"./scripts/verify.sh: OK"
|
||||
],
|
||||
"notes": "Logout smoke verificado: cookie borrada (Max-Age=0 + Expires 1970) y fila de sesión con revoked_at IS NOT NULL en DB."
|
||||
}
|
||||
28
work/artifacts/F-005/reviewer.json
Normal file
28
work/artifacts/F-005/reviewer.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"feature_id": "F-005",
|
||||
"agent": "reviewer",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "Identity module cumple specs/F-005 DESIGN.md: hexagonal real (domain sin framework, ports en domain, infra los implementa), sesiones server-side con token hash en DB, rate limit detrás de interfaz, envelope v2 intacto. Fix de review aplicado (tests COOKIE_SECURE).",
|
||||
"checks": {
|
||||
"architecture_hexagonal": "PASS: domain no toca framework; application depende de ports; infra implementa; api es adaptador delgado con parseJson",
|
||||
"module_boundaries": "PASS: lint:boundaries 39 files OK; identity tests importan solo su subárbol; integración DB vive en src/app/tests (R1/R2)",
|
||||
"server_side_authority": "PASS: expiración y revocación de sesión resueltas en SQL; el frontend no decide estado de identidad",
|
||||
"naming_convention": "PASS: identity_users / identity_sessions siguen <module>_<table>",
|
||||
"migration_reversible": "PASS: down con count:0 (full revert) testeado en integration suite; identity tables + app_meta caen limpias",
|
||||
"error_envelope_v2": "PASS: mapeo AppError con codes estables (INVALID_CREDENTIALS, EMAIL_ALREADY_REGISTERED, TOO_MANY_ATTEMPTS); 5xx genérico preservado",
|
||||
"test_coverage": "PASS: 47 unit + 14 integration; los 6 AC cubiertos por tests automatizados o evidencia live"
|
||||
},
|
||||
"findings": [
|
||||
{
|
||||
"severity": "fixed_in_review",
|
||||
"finding": "COOKIE_SECURE (config nueva de F-005) no tenía tests dedicados",
|
||||
"resolution": "Test agregado: default true, parse true/false, inválido reporta la variable. Suite verde."
|
||||
},
|
||||
{
|
||||
"severity": "follow_up",
|
||||
"finding": "server.ts no tiene graceful shutdown (SIGTERM -> close app + pool.end)",
|
||||
"resolution": "Preexistente de F-001/F-003; fuera del scope de identidad. Registrar como mejora de infraestructura."
|
||||
}
|
||||
],
|
||||
"notes": "Rate limiter in-memory por instancia es limitación documentada y aceptada; interfaz permite swap a Redis sin tocar casos de uso."
|
||||
}
|
||||
22
work/artifacts/F-005/security.json
Normal file
22
work/artifacts/F-005/security.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"feature_id": "F-005",
|
||||
"agent": "security",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "El servidor es la única autoridad de identidad. Credenciales solo como argon2id PHC, sesiones opacas con hash persistido, cookies HttpOnly/Secure/SameSite, anti-enumeración con timing igualado y 401 genérico, brute force contenido con 429 + Retry-After. Sin hallazgos bloqueantes.",
|
||||
"checks": {
|
||||
"credential_storage": "PASS: argon2id m=19456KiB t=2 p=1 (OWASP 2024); integration test prueba que el plaintext no aparece en ningún valor de la fila",
|
||||
"user_enumeration": "PASS: 401 genérico idéntico para email inexistente y password incorrecto (assert + curl live); dummy-hash verify memoizado iguala timing",
|
||||
"sql_injection": "PASS: todas las queries parametrizadas; email validado por zod + citext UNIQUE en DB (race-safe)",
|
||||
"session_token": "PASS: 512 bits crypto.randomBytes base64url; solo SHA-256 hex persiste (token_hash UNIQUE); fuga de DB no entrega sesiones utilizables",
|
||||
"cookie_flags": "PASS: HttpOnly + Secure + SameSite=Lax + Path=/ + Max-Age=604800 verificados en vivo; COOKIE_SECURE default true",
|
||||
"revocation": "PASS: logout revoca server-side (WHERE revoked_at IS NULL AND expires_at > now()); expiración en SQL, no en memoria",
|
||||
"brute_force": "PASS: 10 fallos por email -> 429 + Retry-After: 900 (verificado en vivo); rate check corre ANTES del lookup en DB",
|
||||
"input_abuse": "PASS: password max 128 chars limita costo argon2 por request; validación zod antes del dominio",
|
||||
"secrets": "PASS: sin secretos hardcodeados; .env gitignored; errores de config nombran variables, nunca valores"
|
||||
},
|
||||
"residual_risks_accepted": [
|
||||
"Sesiones concurrentes ilimitadas por usuario (sin cap). Aceptable en este slice.",
|
||||
"Rate limiter in-memory: se pierde con restart y es por instancia. Documentado; interfaz lista para Redis.",
|
||||
"Lockout temporal: un atacante puede bloquear un email 15 min (trade-off estándar); cooldown acotado lo contiene."
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user