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
1. Request Reset Link (Actor: acheteur)
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") → 400TOKEN_INVALID. - Missing
acheteurIdin payload → 400TOKEN_INVALID.
Outcome: Validated acheteurId.
5. Password Reset & Session Revocation (Actor: system)
The new password is bcrypt-hashed. In a single Prisma transaction:
acheteur.passwordHashis updated.- All rows in
acheteur_refresh_tokensfor 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
Related Processes
- registration-email-verification — the account must exist; Google-only accounts (
passwordHash: null) cannot use this flow - profile-management — for changing password while already authenticated