Acheteur Password Recovery

Summary

An acheteur who has forgotten their password requests a reset link by email. The system always returns a success response regardless of whether the email exists (anti-enumeration). If the account is found, a short-lived signed JWT is embedded in the reset URL and sent via Resend. The acheteur clicks the link, submits a new password, and all existing refresh tokens are invalidated. Google-only accounts (no passwordHash) are not affected — they cannot use this flow.

State Diagram

stateDiagram-v2
    state "Réinitialisation demandée" as ForgotRequested
    state "Email envoyé" as EmailSent
    state "Réponse silencieuse" as SilentNoop
    state "Lien cliqué" as LinkClicked
    state "Token expiré" as TokenExpired
    state "Token invalide" as TokenInvalid
    state "Mot de passe réinitialisé" as PasswordReset
    state "Sessions révoquées" as SessionsRevoked
    [*] --> ForgotRequested: acheteur requests password reset
    ForgotRequested --> EmailSent: account found, email dispatched
    ForgotRequested --> SilentNoop: account not found (anti-enumeration)
    EmailSent --> [*]: 200 (ambiguous response)
    SilentNoop --> [*]: 200 (same response)
    EmailSent --> LinkClicked: acheteur clicks reset URL
    LinkClicked --> TokenExpired: JWT expired
    LinkClicked --> TokenInvalid: JWT invalid or wrong purpose
    LinkClicked --> PasswordReset: valid token + new password submitted
    TokenExpired --> [*]: 400 TOKEN_EXPIRED
    TokenInvalid --> [*]: 400 TOKEN_INVALID
    PasswordReset --> SessionsRevoked: all refresh tokens deleted
    SessionsRevoked --> [*]: 200 — password reset confirmed

Steps

Client POSTs { email } to POST /acheteur/auth/forgot-password.

Rate limit: 3 requests per hour per IP.

Outcome: Request reaches the handler.

2. Account Lookup & Email Dispatch (Actor: system)

The system looks up the acheteur by email. If not found, the handler silently exits and returns 200 — the response is identical whether the account exists or not, preventing email enumeration attacks.

If the account is found, buildPasswordResetUrl() embeds a signed JWT (purpose: "password_reset") in the URL. The email is sent via Resend using the acheteur-reset-password template. Email send failures are logged but do not change the 200 response (no Slack alert on this path, unlike registration).

Outcome: Reset email sent if account exists, always 200 { data: { message: "Si ce compte existe, un email a été envoyé" } }.

3. Submit New Password (Actor: acheteur)

Acheteur clicks the link, the frontend extracts the token and POSTs { token, newPassword } to POST /acheteur/auth/reset-password.

Rate limit: 5 requests per hour per IP.

Outcome: Request reaches the handler.

4. Token Verification (Actor: system)

The JWT is verified against EMAIL_VERIFY_JWT_SECRET:

  • Expired → 400 TOKEN_EXPIRED.
  • Invalid signature or wrong purpose (!== "password_reset") → 400 TOKEN_INVALID.
  • Missing acheteurId in payload → 400 TOKEN_INVALID.

Outcome: Validated acheteurId.

5. Password Reset & Session Revocation (Actor: system)

The new password is bcrypt-hashed. In a single Prisma transaction:

  1. acheteur.passwordHash is updated.
  2. All rows in acheteur_refresh_tokens for this acheteur are deleted.

Session revocation ensures any device using a stolen session is logged out.

Outcome: 200 { data: { message: "Mot de passe réinitialisé" } }. Acheteur must log in again.

Error States

  • Token expired → 400 TOKEN_EXPIRED — “Le lien de réinitialisation a expiré.”
  • Token invalid or wrong purpose → 400 TOKEN_INVALID — “Le lien de réinitialisation est invalide.”
  • Account deleted between token issue and reset → 404 NOT_FOUND