Files
mercadodevida/work/artifacts/F-083/architect.md
2026-08-20 06:08:32 +02:00

4.3 KiB

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

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.