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

@@ -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).

View 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

View 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`.

View 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"
}

View 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."
}

View 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."
}

View 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."
]
}

View File

@@ -1,17 +1,16 @@
# Sesión actual
# Current work
- Feature en curso: _ninguna_ (F-004 cerrada DONE el 2026-08-14)
- Inicio: —
- Orquestador: —
- Active feature: none (idle)
- Last closed: F-005 — Identity: register, login, sessions
- Next suggested: F-006 — Users: profile, addresses, RBAC (depends on F-005, satisfied)
- Runtime status: reset via scripts/agent_status.py
- verify.sh: green at close
## Plan
- Hechas: F-001, F-002, F-003, F-004.
- Desbloqueada: F-005 (identity & auth core) — depende de F-002+F-003, ambas done. Config/flags (F-004) ya listos para secrets y guards.
- Sugerencia de orden: F-005 ahora.
## Bitácora
- 2026-08-14: F-001..F-004 DONE; todos los gates APPROVED; verify.sh verde.
- Dev PostgreSQL + Redis siguen corriendo (mdv-dev-postgres, mdv-dev-redis).
## Próximo paso
- intake de F-005 (identity & auth core).
## F-005 closure notes
- Identity module hexagonal: domain/application/infrastructure/api under src/modules/identity
- Sessions server-side: cookie carries opaque 512-bit token; identity_sessions stores only SHA-256 hash
- argon2id (OWASP params) behind PasswordHasher port; timing equalized 401 (no enumeration)
- Rate limit: 10 consecutive failures per email -> 429 + Retry-After (15 min cooldown), in-memory behind interface
- Cookie: HttpOnly + Secure (COOKIE_SECURE, default true) + SameSite=Lax
- Migration 002_identity reversible; integration suite updated for full revert (count:0)
- Gates: reviewer/security/qa APPROVED. Commits include specs, artifacts, code, docs.

View File

@@ -26,3 +26,10 @@
- Nota: los tests detectaron un bug real en build (case-normalization del store); corregido antes de gates con test de regresión
- Cero dependencias nuevas
- Artefactos: work/artifacts/F-004/
## 2026-08-14 — F-005 Identity: register, login, sessions — DONE
- Gates: reviewer APPROVED, security APPROVED, qa APPROVED, verify.sh exit 0
- Entregable: módulo identity hexagonal (domain/application/infrastructure/api), migración 002 reversible, argon2id OWASP tras puerto PasswordHasher, sesiones opacas (solo SHA-256 del token en DB), cookie HttpOnly+Secure+SameSite=Lax, rate limit 10 fallos -> 429 + Retry-After tras interfaz, 401 idéntico anti-enumeración con timing igualado
- Nota: review detectó falta de tests para COOKIE_SECURE; fix aplicado antes de aprobar el gate. Suite de migraciones F-002 actualizada a rollback completo (count:0) por tener ahora 2 migraciones
- Deps nuevas: argon2, @fastify/cookie (justificadas en spec/tech.md)
- Artefactos: work/artifacts/F-005/

View File

@@ -1,11 +1,152 @@
{
"feature_id": null,
"stage": "idle",
"feature_id": "F-005",
"stage": "close",
"agent": "leader",
"action": "Sin ejecución activa",
"state": "waiting",
"action": "F-005 cerrada con gates aprobados",
"state": "done",
"next_agent": "leader",
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
"updated_at": "2026-08-14T20:29:18Z",
"timeline": []
"updated_at": "2026-08-14T20:58:24Z",
"timeline": [
{
"ts": "2026-08-14T20:39:25Z",
"agent": "leader",
"stage": "intake",
"state": "done",
"message": "Intake F-005 OK"
},
{
"ts": "2026-08-14T20:39:25Z",
"agent": "architect",
"stage": "design",
"state": "running",
"message": "Diseño módulo identity (hexagonal)"
},
{
"ts": "2026-08-14T20:41:17Z",
"agent": "architect",
"stage": "design",
"state": "done",
"message": "Diseño F-005 aprobado"
},
{
"ts": "2026-08-14T20:41:17Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Implementando módulo identity"
},
{
"ts": "2026-08-14T20:51:41Z",
"agent": "implementer",
"stage": "build",
"state": "done",
"message": "Build F-005 completo con evidencia"
},
{
"ts": "2026-08-14T20:53:01Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Fix de review: cobertura COOKIE_SECURE"
},
{
"ts": "2026-08-14T20:54:22Z",
"agent": "implementer",
"stage": "build",
"state": "done",
"message": "Fix de review aplicado y verificado"
},
{
"ts": "2026-08-14T20:54:22Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "running",
"message": "Review gate F-005"
},
{
"ts": "2026-08-14T20:54:44Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "done",
"message": "Review gate APPROVED"
},
{
"ts": "2026-08-14T20:55:17Z",
"agent": "security",
"stage": "security_gate",
"state": "running",
"message": "Security gate F-005"
},
{
"ts": "2026-08-14T20:55:17Z",
"agent": "security",
"stage": "security_gate",
"state": "done",
"message": "Security gate APPROVED"
},
{
"ts": "2026-08-14T20:55:17Z",
"agent": "qa",
"stage": "qa_gate",
"state": "running",
"message": "QA gate F-005"
},
{
"ts": "2026-08-14T20:55:36Z",
"agent": "qa",
"stage": "qa_gate",
"state": "done",
"message": "QA gate APPROVED"
},
{
"ts": "2026-08-14T20:55:36Z",
"agent": "documenter",
"stage": "document",
"state": "running",
"message": "Documentación F-005"
},
{
"ts": "2026-08-14T20:55:42Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Docs README: auth contract"
},
{
"ts": "2026-08-14T20:57:22Z",
"agent": "implementer",
"stage": "build",
"state": "done",
"message": "README auth contract documentado"
},
{
"ts": "2026-08-14T20:57:22Z",
"agent": "documenter",
"stage": "document",
"state": "running",
"message": "Documentación F-005"
},
{
"ts": "2026-08-14T20:58:18Z",
"agent": "documenter",
"stage": "document",
"state": "done",
"message": "Docs F-005 completas"
},
{
"ts": "2026-08-14T20:58:24Z",
"agent": "leader",
"stage": "close",
"state": "running",
"message": "Cierre F-005"
},
{
"ts": "2026-08-14T20:58:24Z",
"agent": "leader",
"stage": "close",
"state": "done",
"message": "F-005 cerrada con gates aprobados"
}
]
}