Files
crewli/dev-docs/AUTH_ARCHITECTURE.md
bert.hausmans 948687f27e feat: enterprise MFA with TOTP, email codes, backup codes, and trusted devices
Three verification methods (TOTP authenticator, email code, backup codes),
trusted device management with 30-day expiry, role-based enforcement for
super_admin and org_admin, admin reset capability, and full test coverage
(46 tests). Modifies login flow to support MFA challenge/response with
temporary session tokens stored in cache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:45:55 +02:00

282 lines
13 KiB
Markdown

# Crewli — Authentication Architecture
> Version: 1.0 — April 2026
> Audience: security auditors, backend developers
---
## 1. Authentication Overview
Crewli uses **stateless token-based authentication** via Laravel Sanctum. Two SPA clients communicate with a single REST API. Tokens are stored exclusively in **httpOnly cookies** set by the server — they are never exposed to JavaScript via response bodies, localStorage, or JS-readable cookies.
### Client Applications
| App | URL (dev) | URL (prod) | Purpose |
|-----|-----------|------------|---------|
| App | localhost:5174 | crewli.app | Organiser dashboard + platform admin (`/platform/*` for super_admin) |
| Portal | localhost:5175 | portal.crewli.app | Volunteers, artists, suppliers |
### Access Modes
The Portal supports two access modes:
1. **Cookie-based** (`auth:sanctum`): volunteers and crew who have a `user_id` — login with email/password, httpOnly cookie set on login
2. **Token-based** (`portal.token` middleware): artists, suppliers, press — stateless per-request token via `Authorization: Bearer` header or `?token=` query parameter. No cookies involved.
---
## 2. Cookie Specification
| App | Cookie Name | Domain | Secure | httpOnly | SameSite | Max-Age |
|-----|-------------|--------|--------|----------|----------|---------|
| App | `crewli_app_token` | `.crewli.app` (prod) / `localhost` (dev) | Yes (prod) | Yes | Strict | 7 days |
| Portal | `crewli_portal_token` | `.crewli.app` (prod) / `localhost` (dev) | Yes (prod) | Yes | Strict | 7 days |
Each SPA gets its own cookie name to prevent shared auth state between apps. The cookie domain is configured via `SESSION_DOMAIN` in `.env`.
---
## 3. Token Lifecycle
### Creation
On successful login (`POST /auth/login`), the server:
1. Validates credentials via `Auth::attempt()`
2. Creates a Sanctum personal access token
3. Resolves the cookie name from the `Origin` header
4. Returns user data in the JSON body (no token in body)
5. Attaches the token as a `Set-Cookie` header with httpOnly flag
### Validation
The `CookieBearerToken` middleware (registered before `auth:sanctum` in the API middleware stack):
1. Reads the `Origin` (or `Referer`) header to identify which app is making the request
2. Resolves the correct cookie name for that app (e.g. portal origin → `crewli_portal_token`)
3. Reads only that cookie and sets `Authorization: Bearer` on the request
4. Sanctum's existing token validation processes the header normally
**Cross-app isolation:** In local development, both SPAs share `localhost` (different ports). Browsers do not scope cookies by port, so both app cookies are sent with every API request. The middleware prevents cross-app authentication by only reading the cookie that matches the requesting app's Origin header. Without this, logging into one app would authenticate the other.
If the `Origin` header is absent (e.g. server-to-server requests), the middleware falls back to the first available cookie. If an `Authorization` header is already present (e.g. from the portal token flow), the middleware skips cookie injection entirely.
### Rotation
`POST /auth/refresh` (authenticated endpoint):
1. Revokes the current access token
2. Creates a new token
3. Returns user data with a new httpOnly cookie
4. Logs the refresh event
Clients should call this endpoint periodically (recommended: every 24 hours) to rotate tokens.
### Expiration
Tokens expire after **7 days** (configured in `config/sanctum.php`). After expiration, Sanctum rejects the token and the client receives a 401. The cookie's `Max-Age` matches the token expiration.
### Revocation
Tokens are revoked on:
- **Logout** (`POST /auth/logout`): current token deleted, cookie expired
- **Password reset**: all user tokens revoked
- **Password change**: other session tokens revoked
- **Email change verification**: all user sessions revoked
- **Token refresh**: old token replaced with new one
---
## 4. CSRF Protection
**CSRF tokens are not required.** The `SameSite=Strict` cookie attribute prevents the browser from sending the auth cookie on cross-origin requests. This means:
- A malicious site cannot forge authenticated requests because the cookie is never attached to cross-origin submissions
- `SameSite=Strict` is stricter than `Lax` — even top-level navigations from other sites will not include the cookie
Reference: [OWASP CSRF Prevention Cheat Sheet — SameSite Cookie Attribute](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#samesite-cookie-attribute)
---
## 5. Attack Surface Analysis
### XSS — Token Theft
**Mitigated.** The bearer token is stored in an `httpOnly` cookie and is never present in:
- The JSON response body
- `localStorage` or `sessionStorage`
- JS-readable cookies (`document.cookie`)
Even if an XSS vulnerability exists, the attacker cannot read the token. They can make authenticated requests from the user's browser session, but cannot exfiltrate the token for use elsewhere.
### CSRF — Cross-Site Request Forgery
**Mitigated.** `SameSite=Strict` prevents the browser from attaching the cookie to any request originating from a different site, including form submissions and top-level navigations.
### Network Interception — Token Theft
**Mitigated in production.** The `Secure` flag ensures the cookie is only sent over HTTPS connections. In development (localhost), `Secure` is disabled to allow HTTP.
### Server Compromise — Token Theft
**Partially mitigated.** Sanctum hashes tokens in the `personal_access_tokens` table using SHA-256. An attacker with database read access sees hashed tokens, not plaintext values. However, an attacker with full server access could intercept tokens in memory.
### Token Fixation
**Not applicable.** Tokens are generated server-side using cryptographically secure random values. The client never provides or influences the token value.
---
## 6. Portal Token-Based Flow (Artists / Suppliers)
This flow is separate from the httpOnly cookie system and is NOT affected by this architecture.
### How It Works
1. The portal generates a unique token per artist/supplier, stored as a SHA-256 hash in the `artists` or `production_requests` table
2. The plaintext token is sent to the person (e.g. via email link)
3. The person accesses a portal URL with the token as a query parameter or `Authorization: Bearer` header
4. `PortalTokenMiddleware` validates the hash, resolves the person and event context
5. The request proceeds with `portal_context`, `portal_person`, and `portal_event` attributes
### Security Properties
- Tokens are hashed at rest (SHA-256)
- No cookies or sessions involved — each request is independently authenticated
- Token validity is tied to event status (draft and closed events reject tokens)
- No user account required — the token IS the identity
---
## 7. Middleware Stack (Relevant Portion)
```
Request
→ CookieBearerToken (reads cookie → injects Authorization header)
→ auth:sanctum (validates bearer token)
→ Controller
```
For portal token routes:
```
Request
→ portal.token (validates portal-specific token)
→ Controller
```
---
## 8. Configuration Reference
| Setting | Location | Purpose |
|---------|----------|---------|
| `SESSION_DOMAIN` | `.env` | Cookie domain (`.crewli.app` in prod, `localhost` in dev) |
| `FRONTEND_APP_URL` | `.env` / `config/app.php` | App SPA origin |
| `FRONTEND_PORTAL_URL` | `.env` / `config/app.php` | Portal SPA origin |
| `sanctum.expiration` | `config/sanctum.php` | Token TTL (7 days = 10080 minutes) |
---
## 9. Multi-Factor Authentication (MFA)
Crewli supports enterprise-grade MFA with three verification methods, trusted device management, role-based enforcement, and admin reset capability.
### 9.1 Verification Methods
| Method | Type | Expiry | Notes |
|--------|------|--------|-------|
| TOTP | Authenticator app (Google Authenticator, Authy, etc.) | 30s per code | Primary, most secure. Secret stored encrypted. |
| Email code | 6-digit code sent via EmailService | 10 min | Fallback for TOTP users, or standalone method. Rate-limited: 1 code per 60s. |
| Backup codes | 10 single-use codes (XXXX-XXXX format) | Never | Generated at MFA setup. Stored as bcrypt hashes. Last resort recovery. |
### 9.2 Login Flow with MFA
```
Client API
│ │
│ POST /auth/login │
│ { email, password } │
│ ─────────────────────────────────►│
│ │ ── Validate credentials
│ │ ── Check MFA enabled?
│ │
│ ┌── MFA NOT enabled ──────────┤
│ │ Return auth token (cookie)│
│ │ │
│ └── MFA enabled ─────────────┤
│ │ │
│ ├── Trusted device? ─ YES ─ Return auth token (skip MFA)
│ │ │
│ └── No trusted device ────┤
│ Return mfa_required │
│ + mfa_session_token │
│ ◄────────────────────────────────│
│ │
│ POST /auth/mfa/verify │
│ { mfa_session_token, code, │
│ method, trust_device? } │
│ ─────────────────────────────────►│
│ │ ── Verify code
│ │ ── Optionally trust device
│ ◄── auth token (cookie) ─────────│
```
### 9.3 MFA Session
After successful password authentication, if MFA is required, the API creates a temporary MFA session:
- Stored in Redis/Cache with prefix `mfa_session:`
- TTL: 10 minutes
- Contains: `user_id`, `ip_address`, `created_at`
- IP address is checked on verification — if it changes, the session is invalidated
- Session is consumed (deleted) after successful MFA verification
### 9.4 Trusted Devices
Users can opt to trust a device during MFA verification. Trusted devices:
- Skip MFA on subsequent logins for 30 days
- Are identified by a SHA-256 hash of `device_fingerprint + user_id`
- The `X-Device-Fingerprint` header must be sent with login requests
- Can be listed, individually revoked, or all revoked by the user
- Are stored in the `trusted_devices` table with ULID primary key
### 9.5 Backup Codes
- 10 codes generated at MFA setup (format: `XXXX-XXXX`)
- Stored as bcrypt hashes — plain codes shown to user only once
- Each code is single-use (marked `used` + `used_at` on verification)
- Can be regenerated (requires TOTP verification)
- Input normalization: spaces and dashes stripped, case-insensitive
### 9.6 Role-Based Enforcement
MFA is required (enforced) for:
- `super_admin` — always
- `org_admin` — always
- Any user in an organisation with `settings.enforce_mfa = true`
- Any user with `mfa_enforced = true` flag
When MFA is required but not yet set up, login succeeds but includes `mfa_setup_required: true` flag. The frontend should redirect to MFA setup.
### 9.7 Admin Reset
Platform admins (`super_admin`) can force-disable MFA for any user via `POST /admin/users/{user}/reset-mfa`. This:
- Clears all MFA data (secret, backup codes, email codes, trusted devices)
- Logs the action in the activity log with `mfa.admin_reset` event
- The user must re-enable MFA on next login if enforcement policies apply
### 9.8 Database Tables
| Table | PK Type | Purpose |
|-------|---------|---------|
| `users` (MFA columns) | — | `mfa_enabled`, `mfa_method`, `mfa_secret` (encrypted), `mfa_confirmed_at`, `mfa_enforced` |
| `mfa_backup_codes` | auto-increment | Hashed single-use recovery codes |
| `mfa_email_codes` | auto-increment | Temporary 6-digit email verification codes |
| `trusted_devices` | ULID | Device trust records with expiry |
### 9.9 Key Files
| File | Purpose |
|------|---------|
| `app/Services/MfaService.php` | Central MFA logic — setup, verification, backup codes, trusted devices, enforcement |
| `app/Enums/MfaMethod.php` | TOTP, EMAIL, BACKUP_CODE enum |
| `app/Http/Controllers/Api/V1/Auth/MfaSetupController.php` | Authenticated MFA setup/disable/status endpoints |
| `app/Http/Controllers/Api/V1/Auth/MfaVerifyController.php` | Login-flow MFA verification (unauthenticated) |
| `app/Http/Controllers/Api/V1/Auth/TrustedDeviceController.php` | Trusted device management |