# F-083 — Architect: Customers password reset via email link ## Goal Secure password reset flow: 1. Customer enters email → backend always returns 200 (no user enumeration), generates single-use signed token with 1h TTL, stores hash, sends email. 2. Customer opens email link → storefront form at `/cuenta/restablecer?token=...` submits new password. 3. Confirm endpoint validates token hash + expiry + unused → updates password hash, marks token used. 4. Rate limit per IP and per email. 5. Admin `/customers` row has a "Send reset link" button that triggers the same flow. 6. Audit log entry for each request and confirm. ## Design ### Backend **Migration `032_password_reset_tokens.js`**: new table ```sql CREATE TABLE identity_password_reset_tokens ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES identity_users(id) ON DELETE CASCADE, token_hash text NOT NULL UNIQUE, expires_at timestamptz NOT NULL, used_at timestamptz, request_ip text, request_user_agent text, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX identity_password_reset_tokens_user_idx ON identity_password_reset_tokens(user_id); CREATE INDEX identity_password_reset_tokens_expires_at_idx ON identity_password_reset_tokens(expires_at); ``` **Domain**: - `PasswordResetToken` entity with `isExpired()`, `isUsed()`. - Errors: `InvalidResetTokenError`, `ExpiredResetTokenError`, `UsedResetTokenError`. **Repository** (`PgPasswordResetTokenRepository`): `create`, `findValidByHash(hash)`, `markUsed(id)`. On create, invalidates all previous unused tokens for that user. **Use cases** (mirror Login/RegisterUser): - `RequestPasswordReset({ email, ip, userAgent })`: - Normalise email; if user does not exist, return success silently (no enumeration). - Invalidate previous unused tokens for that user. - Generate cryptographically random token (32 bytes hex → 64 chars), store `hash(token)`. - Rate limit: per-IP bucket of 5/hour and per-email bucket of 3/hour. Returns success even when rate-limited (no enumeration). - Persist token row with `expires_at = now() + 1h`. - Emit `password_reset.requested` audit log entry. - Trigger email via `notifications.sendPasswordReset({ email, resetUrl, locale })`. - `ConfirmPasswordReset({ token, newPassword })`: - Hash incoming token, look up by hash. - Reject (HTTP 400) if not found / expired / used → same error code in all cases. - Update `users.passwordHash` via `PasswordHasher.hash(newPassword)`. - Mark token used. - Emit `password_reset.confirmed` audit log. **Routes**: ``` POST /auth/password-reset/request { email } -> 200 { ok: true } always POST /auth/password-reset/confirm { token, password } -> 200 { ok: true } | 400 ``` Both are public (no session). Request endpoint should be slow when user not found to equalise timing. ### Notifications Use existing `notifications.sendEmail` with a new template `password-reset`. Template renders the link with `{{ resetUrl }}`. ### Admin In `/customers` row, add a button "Enviar enlace de recuperación" that POSTs to `/auth/password-reset/request` with the row's email. Show toast on result. ### Storefront New page `/cuenta/restablecer` with form for token (from query) + new password + confirm. POSTs to `/auth/password-reset/confirm`. ## Risk Medium. Touches auth & email. Mitigations: - Tokens are stored as hashes (not raw). - Single-use enforcement at DB level (mark used atomically in the same UPDATE that validates expiry). - Rate limit prevents brute force / abuse. - Always-200 on request prevents user enumeration. - Audit log for traceability. ## Acceptance mapping - "Backend POST /auth/password-reset/request always returns 200" → request handler always 200. - "Signed single-use token with TTL ≥30 min ≤24h" → 1h TTL, hash stored. - "Email sent with /cuenta/restablecer?token=..." → notifications.sendPasswordReset. - "POST /auth/password-reset/confirm rejects invalid/expired/used" → 400 from use case. - "New password works for login" → users.passwordHash updated. - "Admin row action triggers same flow" → POST /auth/password-reset/request. - "Rate limit per IP and per email" → dual-bucket limiter. - "Audit log entries" → security_audit_log rows on request and confirm. - "verify.sh is green" → typecheck + lint + tests.