188 lines
13 KiB
Markdown
188 lines
13 KiB
Markdown
---
|
||
title: Authentication Flow
|
||
tags: [flow, auth, jwt, login, security]
|
||
related_models: ["[[User]]", "[[TempVerification]]"]
|
||
related_apis: ["[[Auth API]]", "POST /api/auth/login", "POST /api/auth/refresh-token", "POST /api/auth/logout"]
|
||
---
|
||
|
||
# Authentication Flow
|
||
|
||
End-to-end specification for **email + password** authentication, JWT issuance, token lifecycle, refresh-token rotation, and logout cleanup. This is the most-used auth path in the marketplace and underpins every protected API call and Socket.IO room subscription.
|
||
|
||
## Actors
|
||
|
||
- **User (Buyer / Seller / Admin)** – submits credentials via the frontend.
|
||
- **Frontend (Next.js)** – the JWT sign-in view in `frontend/src/auth/view/jwt/jwt-sign-in-view.tsx`, which delegates to `signInWithPassword()` in `frontend/src/auth/context/jwt/action.ts`.
|
||
- **Backend (Express)** – `AuthController.login` in `backend/src/services/auth/authController.ts`.
|
||
- **MongoDB** – `User` collection (refresh tokens are appended to `user.refreshTokens[]`).
|
||
- **Redis** – session store (`sessionService.createSession`) and login-attempt rate-limiter (`rateLimitService.checkLoginAttempts`).
|
||
- **Socket.IO** – not directly involved in login, but the issued JWT is later used to join `user-${userId}` rooms (see [[Notification Flow]] and [[Chat Flow]]).
|
||
|
||
## Preconditions
|
||
|
||
- The user has already completed [[Registration Flow]] and their `User.isEmailVerified === true`.
|
||
- The user's `User.status === "active"` (soft-deleted accounts have status `"deleted"`).
|
||
- Network connectivity is available (the frontend uses `NetworkUtils.isOnline()` from `frontend/src/auth/utils/error-handler.ts`).
|
||
- `localStorage` is available — the frontend rejects the request early via `StorageUtils.isAvailable()` if storage is blocked.
|
||
- Backend env vars `JWT_SECRET`, `JWT_EXPIRES_IN`, `REFRESH_TOKEN_EXPIRES_IN` are set (`backend/src/services/auth/authService.ts:19-21`).
|
||
|
||
## Step-by-step narrative
|
||
|
||
1. **User fills the sign-in form** at `/auth/jwt/sign-in` and clicks "Sign in". The form is implemented in `frontend/src/auth/view/jwt/jwt-sign-in-view.tsx`.
|
||
2. **Client-side guards**: `signInWithPassword()` (`action.ts:32-116`) verifies the browser is online and `localStorage` is writable; otherwise it throws a typed `AuthErrorHandler` error.
|
||
3. **HTTP request**: The frontend POSTs `{ email, password }` to `POST /api/auth/login` (resolved by `endpoints.auth.login` in `frontend/src/lib/axios.ts`). An `AbortController` is armed with a 60-second timeout.
|
||
4. **Validation middleware** runs `loginValidation` (`backend/src/services/auth/authValidation.ts`) — wires into Express via `authRoutes.ts:22`.
|
||
5. **Rate limiting**: `rateLimitService.checkLoginAttempts(email, 5, 15*60)` is called (`authController.ts:173`). Five failures within 15 minutes returns `429 TOO_MANY_ATTEMPTS`. Counters live in Redis so they survive restarts.
|
||
6. **User lookup**: `User.findOne({ email, status: "active" }).select("+password")` — `password` is `select: false` by default in the schema and must be explicitly projected.
|
||
7. **Password comparison**: `authService.comparePassword()` invokes `bcrypt.compare()` (cost factor 12 — see `authService.ts:102-105`). Constant-time per bcrypt's design.
|
||
8. **Email-verification gate**: If `!user.isEmailVerified`, returns `403 EMAIL_NOT_VERIFIED` with `needsVerification: true`. The frontend intercepts this in `action.ts:104-111` and redirects to `/auth/jwt/verify?email=...`.
|
||
9. **Reset attempts**: On success, `rateLimitService.resetLoginAttempts(email)` wipes the Redis counter.
|
||
10. **Last-login stamp**: `user.lastLoginAt = new Date()` is saved.
|
||
11. **Token issuance**:
|
||
- **Access token** — `authService.generateToken()` builds a JWT with `{ id, email, role, isEmailVerified, iat }`, signed with `HS256`, `issuer: 'marketplace-backend'`, `audience: 'marketplace-users'`, default `expiresIn` from config.
|
||
- **Refresh token** — `authService.generateRefreshToken()` issues a separate JWT with `{ id, type: 'refresh' }` and a longer TTL.
|
||
12. **Refresh-token persistence**: The new refresh token is appended to the `User.refreshTokens` array (`authController.ts:230-231`). This array is the server-side allow-list — only tokens present here can be used to mint new access tokens.
|
||
13. **Redis session**: `sessionService.createSession(accessToken, userId, email, role, ip, userAgent, 86400)` stores a 24h session record keyed by the access token (`authController.ts:235-247`). Failures are logged but do **not** block login.
|
||
14. **Response**: `200 OK` with `{ user: user.toJSON(), tokens: { accessToken, refreshToken } }`. `toJSON()` strips `password`, `refreshTokens`, and verification codes (see User model `toJSON` transform).
|
||
15. **Client-side storage**: The frontend writes both tokens to `localStorage` via `StorageUtils.safeSet()` — keys `accessToken` and `refreshToken` (`action.ts:77-82`).
|
||
|
||
> [!warning] Token storage is `localStorage`, not cookies
|
||
> Tokens are persisted in `window.localStorage`. This is intentional (the API is a separate origin and the app is fully SPA-like), but it means tokens are reachable from any script running on the page. Mitigations in place: strict CSP via `helmet`, no third-party scripts in the auth views, and short access-token TTL with refresh rotation. There are **no httpOnly auth cookies**.
|
||
|
||
16. **Axios interceptor** (`frontend/src/lib/axios.ts`) attaches `Authorization: Bearer ${accessToken}` to every subsequent request and, on `401/403`, automatically calls the refresh flow described below.
|
||
17. **Socket.IO bootstrap**: After login, the dashboard layout connects to Socket.IO and emits `join-user-room`, `join-buyer-room`/`join-seller-room` based on `user.role`. See `backend/src/app.ts:83-126`.
|
||
|
||
## Sequence diagram
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
autonumber
|
||
actor U as User
|
||
participant FE as Frontend (Next.js)
|
||
participant BE as Backend (Express)
|
||
participant DB as MongoDB
|
||
participant R as Redis
|
||
participant IO as Socket.IO
|
||
|
||
U->>FE: Enter email + password, click Sign in
|
||
FE->>FE: NetworkUtils.isOnline() / StorageUtils.isAvailable()
|
||
FE->>BE: POST /api/auth/login { email, password }
|
||
BE->>R: rateLimitService.checkLoginAttempts(email)
|
||
R-->>BE: { allowed: true, remaining }
|
||
BE->>DB: User.findOne({ email, status: "active" }).select("+password")
|
||
DB-->>BE: user document
|
||
BE->>BE: bcrypt.compare(password, user.password)
|
||
alt password invalid
|
||
BE-->>FE: 401 Invalid credentials
|
||
else email not verified
|
||
BE-->>FE: 403 EMAIL_NOT_VERIFIED
|
||
FE-->>U: Redirect /auth/jwt/verify
|
||
else success
|
||
BE->>R: rateLimitService.resetLoginAttempts(email)
|
||
BE->>DB: user.lastLoginAt = now; user.refreshTokens.push(refresh)
|
||
BE->>BE: generateToken(authUser) / generateRefreshToken(authUser)
|
||
BE->>R: sessionService.createSession(accessToken, ...)
|
||
BE-->>FE: 200 { user, tokens: { accessToken, refreshToken } }
|
||
FE->>FE: localStorage.setItem('accessToken' / 'refreshToken')
|
||
FE->>IO: socket.emit('join-user-room', userId)
|
||
FE-->>U: Redirect to /dashboard
|
||
end
|
||
```
|
||
|
||
## API calls
|
||
|
||
| Method | Endpoint | Source |
|
||
|---|---|---|
|
||
| `POST` | `/api/auth/login` | `backend/src/services/auth/authRoutes.ts:22` → `authController.login` |
|
||
| `POST` | `/api/auth/refresh-token` | `authRoutes.ts:24-27` → `authController.refreshToken` |
|
||
| `POST` | `/api/auth/logout` | `authRoutes.ts:68` → `authController.logout` (protected) |
|
||
| `GET` | `/api/auth/profile` | `authRoutes.ts:69` → `authController.getProfile` |
|
||
|
||
## Database writes
|
||
|
||
- **`users` collection**: `lastLoginAt` updated; `refreshTokens` array gains one entry per successful login or refresh.
|
||
- No write at all on a failed attempt (only Redis counter increments).
|
||
- On `changePassword` / `resetPassword`: `refreshTokens` is reset to `[]` (forces re-login on every device — `authController.ts:600` and `:685`).
|
||
|
||
## Socket events emitted
|
||
|
||
- Login itself emits nothing. After the response, the frontend emits the **client-side** events `join-user-room`, `join-buyer-room` or `join-seller-room` to subscribe to targeted notifications.
|
||
|
||
## Side effects
|
||
|
||
- **Redis session**: 24-hour key holding `{ userId, email, role, ip, userAgent }`. Used for forced logout (admin can call `sessionService.deleteSession(token)`).
|
||
- **Redis rate-limit counter**: TTL 15 min, reset on success.
|
||
- **No email** is sent on a normal login (no "new sign-in" notification today — opportunity for future enhancement).
|
||
- **Sentry**: any unexpected exception bubbles to `Sentry.setupExpressErrorHandler` (`app.ts:351`).
|
||
|
||
## Refresh-token flow
|
||
|
||
The access token is short-lived. When a protected request returns `401 TOKEN_INVALID` or `403`, the axios interceptor calls:
|
||
|
||
1. `POST /api/auth/refresh-token` with `{ refreshToken }` from `localStorage`.
|
||
2. Backend `authController.refreshToken` (`:263-313`) verifies the token via `verifyRefreshToken`, checks it is **still present in `user.refreshTokens[]`**, then issues a brand-new access **and** refresh token.
|
||
3. The old refresh token is **removed** from the array and the new one is pushed — implementing **refresh-token rotation**. A leaked-but-stale token therefore becomes invalid the moment the legitimate user refreshes.
|
||
4. The new pair is written back to `localStorage` and the original failed request is retried.
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
autonumber
|
||
participant FE as Frontend axios
|
||
participant BE as Backend
|
||
participant DB as MongoDB
|
||
|
||
FE->>BE: GET /api/marketplace/... (Bearer access)
|
||
BE-->>FE: 401 TOKEN_INVALID
|
||
FE->>BE: POST /api/auth/refresh-token { refreshToken }
|
||
BE->>BE: verifyRefreshToken(refreshToken)
|
||
BE->>DB: User.findById(decoded.id); ensure refresh ∈ user.refreshTokens
|
||
BE->>BE: Generate new access + refresh tokens
|
||
BE->>DB: user.refreshTokens = [...minus old, new]
|
||
BE-->>FE: 200 { tokens: { accessToken, refreshToken } }
|
||
FE->>BE: GET /api/marketplace/... (Bearer new access) — retry
|
||
```
|
||
|
||
## Logout flow
|
||
|
||
1. Frontend `signOut()` (`action.ts:146-176`) reads `refreshToken` from `localStorage` and POSTs `/api/auth/logout` with a 10-second timeout.
|
||
2. Backend `authController.logout` (`:316-344`) removes the refresh token from `user.refreshTokens[]` and calls `sessionService.deleteSession(accessToken)`.
|
||
3. **Always-clear**: the frontend's `finally` block removes both `accessToken` and `refreshToken` from `localStorage` regardless of network success — meaning even an offline logout effectively signs the user out locally.
|
||
|
||
> [!tip] Force-logout an entire user
|
||
> Setting `user.refreshTokens = []` in MongoDB instantly invalidates all sessions on next refresh. `changePassword`, `resetPassword`, and `deleteAccount` all do this.
|
||
|
||
## Error / edge cases
|
||
|
||
- **Wrong password** → `401 Invalid credentials` (intentionally vague — no distinction between "unknown email" and "wrong password").
|
||
- **Email unverified** → `403 EMAIL_NOT_VERIFIED`; frontend auto-redirects to verify page.
|
||
- **5+ failures in 15 min** → `429 TOO_MANY_ATTEMPTS`; only an admin can manually clear via Redis.
|
||
- **Network timeout** → axios `AbortController` cancels at 60s; frontend shows a typed error and the user can retry.
|
||
- **Redis down** → login still succeeds (session creation is best-effort, wrapped in try/catch at `authController.ts:234-247`). Rate limiting falls back to the in-memory map in `authService.ts:113-145` if `rateLimitService` itself throws.
|
||
- **Stale refresh token** (rotated by another device) → `403 Invalid refresh token`. Frontend signs out and redirects to sign-in.
|
||
- **JWT signature mismatch** (secret rotated) → all sessions invalidated server-side; clients clear tokens on first 401.
|
||
- **Token issued for another audience/issuer** → `verifyToken` returns `null` (`authService.ts:60-79`), middleware returns `403 TOKEN_INVALID`.
|
||
- **Refresh token used as access token** → blocked by the `if (decoded.type === 'refresh') return null` check in `verifyToken` (`authService.ts:67`). This is critical: a leaked refresh token alone cannot read protected data.
|
||
- **Soft-deleted account** → `User.findOne({ status: "active" })` filter excludes deleted accounts; login fails as if the email did not exist.
|
||
|
||
> [!warning] Constant-time response is approximate
|
||
> Today we return `401` immediately when the user is missing, before running bcrypt. This is a timing oracle that lets an attacker enumerate registered emails by response-time analysis. Mitigation tracked separately — the recommendation is to always run a dummy bcrypt compare on missing users.
|
||
|
||
## Linked flows
|
||
|
||
- [[Registration Flow]] — produces the `User` document this flow consumes.
|
||
- [[Password Reset Flow]] — alternate entry into the account if credentials are lost.
|
||
- [[Google OAuth Flow]] — parallel auth path that produces equivalent tokens.
|
||
- [[Passkey (WebAuthn) Flow]] — passwordless alternative.
|
||
- [[Chat Flow]], [[Notification Flow]] — both consume the access token to authorise Socket.IO rooms.
|
||
|
||
## Source files
|
||
|
||
- Backend: `backend/src/services/auth/authController.ts:161-260`
|
||
- Backend: `backend/src/services/auth/authService.ts:24-99`
|
||
- Backend: `backend/src/services/auth/authRoutes.ts:22`
|
||
- Backend: `backend/src/services/redis/sessionService.ts`
|
||
- Backend: `backend/src/services/redis/rateLimitService.ts`
|
||
- Frontend: `frontend/src/auth/context/jwt/action.ts:32-176`
|
||
- Frontend: `frontend/src/auth/view/jwt/jwt-sign-in-view.tsx`
|
||
- Frontend: `frontend/src/lib/axios.ts` (interceptor + endpoints)
|