Files
nick-doc/04 - Flows/Password Reset Flow.md
2026-05-23 20:35:34 +03:30

125 lines
6.0 KiB
Markdown

---
title: Password Reset Flow
tags: [flow, auth, password-reset, email]
related_models: ["[[User]]"]
related_apis: ["POST /api/auth/request-password-reset", "POST /api/auth/reset-password-with-code"]
---
# Password Reset Flow
Self-service password recovery: request a 6-digit code by email, submit it with the new password.
## Actors
- **User** who has forgotten their password.
- **Frontend** — `frontend/src/auth/view/jwt/jwt-reset-password-view.tsx` (request) and `jwt-update-password-view.tsx` (submit new password).
- **Backend** — `AuthController.requestPasswordReset` and `AuthController.resetPasswordWithCode` in `backend/src/services/auth/authController.ts`.
- **MongoDB** — `User` collection (`passwordResetCode`, `passwordResetCodeExpires`, `refreshTokens`).
- **Email service** — `emailService.sendPasswordResetCodeEmail`.
## Preconditions
- The account exists and `status === "active"` (deleted accounts are silently treated as non-existent).
- The user has access to the email inbox associated with the account.
- A 6-digit code is valid for **1 hour** (`authController.ts:556`).
## Step-by-step narrative
1. User clicks "Forgot password?" on the sign-in page and lands at `/auth/jwt/reset-password`.
2. User enters their email and submits.
3. Frontend POSTs `POST /api/auth/request-password-reset { email }`.
4. Backend `authController.requestPasswordReset` (`:542-574`):
- `User.findOne({ email, status: "active" })`. If absent, returns `200` with the same generic message — **no enumeration**.
- Generates a 6-digit code via `authService.generateVerificationCode()`.
- Saves `passwordResetCode` and `passwordResetCodeExpires = now + 3_600_000 ms` on the user.
- Calls `emailService.sendPasswordResetCodeEmail(email, firstName, code)`.
5. Response: `200 "If an account with this email exists, a password reset code has been sent"` regardless of outcome.
6. User receives the email and enters the code + new password on `/auth/jwt/update-password`.
7. Frontend POSTs `POST /api/auth/reset-password-with-code { email, code, password }`.
8. Backend `authController.resetPasswordWithCode` (`:611-657`):
- Validates code format `/^\d{6}$/`.
- `User.findOne({ email, passwordResetCode: code, passwordResetCodeExpires: { $gt: now }, status: "active" })`. Mismatch → `400 Invalid or expired reset code`.
- Hashes the new password with bcrypt cost 12.
- Sets `user.password = hashed`, clears `passwordResetCode` and `passwordResetCodeExpires`, **wipes `user.refreshTokens = []`** to invalidate all existing sessions.
- Saves.
9. Response: `200 "Password reset successfully"`. Frontend redirects to `/auth/jwt/sign-in` for a fresh login.
## Sequence diagram
```mermaid
sequenceDiagram
autonumber
actor U as User
participant FE as Frontend
participant BE as Backend
participant DB as MongoDB
participant MAIL as Email Service
U->>FE: Click "Forgot password", enter email
FE->>BE: POST /api/auth/request-password-reset { email }
BE->>DB: User.findOne({ email, status: "active" })
alt user found
BE->>BE: code = generateVerificationCode()
BE->>DB: user.passwordResetCode = code\nexpires = +1h
BE->>MAIL: sendPasswordResetCodeEmail(email, firstName, code)
MAIL-->>U: Email with 6-digit code
end
BE-->>FE: 200 "if account exists, code sent"
U->>FE: Enter code + new password
FE->>BE: POST /api/auth/reset-password-with-code { email, code, password }
BE->>DB: User.findOne({ email, code, expires>now })
BE->>BE: bcrypt.hash(password, 12)
BE->>DB: user.password = hash\nuser.refreshTokens = []\nclear reset fields
BE-->>FE: 200 "Password reset successfully"
FE-->>U: Redirect /auth/jwt/sign-in
```
## API calls
| Method | Endpoint | Source |
|---|---|---|
| `POST` | `/api/auth/request-password-reset` | `authRoutes.ts:44-47` |
| `POST` | `/api/auth/reset-password-with-code` | `authRoutes.ts:54-56` |
| `POST` | `/api/auth/reset-password` | `authRoutes.ts:49-52` (legacy token-based variant) |
## Database writes
- **`users` collection**: on request, sets `passwordResetCode` + `passwordResetCodeExpires`. On submit, replaces `password`, clears reset fields, and empties `refreshTokens`.
## Socket events emitted
- None.
## Side effects
- **Email**: one transactional message containing the 6-digit code.
- **Server-side log**: `authController.ts:559` `console.log` includes the generated code in plain text — same hardening note as [[Registration Flow]].
- **Session invalidation**: All refresh tokens cleared → all devices forced to re-login after password change. Access tokens still valid until expiry (typically minutes).
## Error / edge cases
- **Unknown email** → always `200`, generic message. No enumeration.
- **Invalid code format** → `400` from `isValidVerificationCode` guard before DB lookup.
- **Expired code** (>1h) → `400 Invalid or expired reset code`.
- **Multiple parallel requests** → each overwrites the previous `passwordResetCode`; the latest email wins, prior codes silently invalidated.
- **User attempts reset on deleted account** → treated as unknown (no email sent, `200` returned).
- **Email delivery failure** → response still `200`; user can request again.
- **Access tokens still valid post-reset** → unavoidable with stateless JWT; mitigated by short TTL. Critical operations should re-verify password.
> [!warning] Plaintext code in logs
> Same as [[Registration Flow]]: the reset code is `console.log`-ed by the controller in all environments. Restrict log access in production or gate the log behind `NODE_ENV !== 'production'`.
## Linked flows
- [[Authentication Flow]] — user re-signs-in after reset.
- [[Registration Flow]] — same code-generation utility.
## Source files
- Backend: `backend/src/services/auth/authController.ts:542-657`
- Backend: `backend/src/services/email/emailService.ts` (`sendPasswordResetCodeEmail`)
- Frontend: `frontend/src/auth/view/jwt/jwt-reset-password-view.tsx`
- Frontend: `frontend/src/auth/view/jwt/jwt-update-password-view.tsx`
- Frontend: `frontend/src/auth/context/jwt/action.ts:181-200`, `:261-276`