Files
crewli/dev-docs/AUTH_ARCHITECTURE.md
bert.hausmans 812cc17460 docs(auth): reflect single-cookie architecture; close A13-3
dev-docs/AUTH_ARCHITECTURE.md (v1.0 → v2.0):
- Title section updated to single-SPA / single-cookie reality
- Client Applications table collapsed to one row
- Cookie Specification table collapsed to one row (crewli_app_token)
- Token Lifecycle / Validation section: Origin-based resolution
  language removed; middleware described as origin-agnostic
- Cross-app isolation paragraph removed (no second app)
- Configuration Reference table marks FRONTEND_PORTAL_URL as legacy,
  pointing at TECH-FRONTEND-URL-CONSOLIDATE
- New §11 "History" preserves the pre-WS-3 dual-cookie context for
  future readers, mentions PR-B2a + PR-B2b roles in the unwind

dev-docs/BACKLOG.md — three new entries:
- TECH-FRONTEND-URL-CONSOLIDATE: refactor email controllers to drop
  per-app URL map (EmailChangeController, PasswordResetController,
  PersonController) — low priority, code-cleanliness only
- TECH-DOCS-APPS-PORTAL-PURGE: sweep apps/portal references from
  briefing/tooling docs (.cursor/, MASTER_PROMPT_*, SETUP, dev-guide,
  CLAUDE_CODE_TOOLING) — single chore(docs) PR, low priority
- OPS — DNS retirement of portal.crewli.app — operational task,
  deferred until traffic monitoring confirms zero usage

dev-docs/SECURITY_AUDIT.md:
- A13-1 narrative actualised: pre-WS-3 dual-cookie context kept as
  history, status flipped to RESOLVED (the localStorage→httpOnly
  migration shipped earlier in the consolidation arc)
- A13-3: status flipped to RESOLVED by WS-3 PR-B2b; description
  rewritten to reflect the new postLoginRedirect.ts validator and
  the 16 spec coverage
- Priority remediation table item 8 strikes through A13-3

Backend test suite: 1487 passed (unchanged from Commit 2 baseline).
Frontend: 223 passed (unchanged from Commit 1 baseline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:29:26 +02:00

405 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Crewli — Authentication Architecture
> Version: 2.0 — May 2026 (post WS-3 PR-B2b: single-cookie consolidation)
> Audience: security auditors, backend developers
---
## 1. Authentication Overview
Crewli uses **stateless token-based authentication** via Laravel Sanctum. A single SPA client communicates 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 Application
| App | URL (dev) | URL (prod) | Purpose |
|-----|-----------|------------|---------|
| SPA | localhost:5174 | crewli.app | Organizers, volunteers, crew, super_admin (context-routed in-app) |
### Access Modes
The SPA supports two access modes:
1. **Cookie-based** (`auth:sanctum`): organizers, volunteers, crew — 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
| Cookie Name | Domain | Secure | httpOnly | SameSite | Max-Age |
|-------------|--------|--------|----------|----------|---------|
| `crewli_app_token` | `.crewli.app` (prod) / `localhost` (dev) | Yes (prod) | Yes | Strict | 7 days |
A single cookie covers all cookie-authenticated traffic. 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
2. Creates a Sanctum personal access token
3. Returns user data in the JSON body (no token in body)
4. Attaches the token as a `Set-Cookie` header with httpOnly flag (cookie name: `crewli_app_token`)
### Validation
The `CookieBearerToken` middleware (registered before `auth:sanctum` in the API middleware stack):
1. Skips if an `Authorization` header is already present (portal-token flow, server-to-server callers)
2. Reads the `crewli_app_token` cookie and sets `Authorization: Bearer <token>` on the request
3. Sanctum's existing token validation processes the header normally
The middleware is origin-agnostic — there is no Origin/Referer parsing or per-app cookie resolution. With only one SPA, cross-app isolation is not a concern.
### 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 (cookie → Authorization header)
→ auth:sanctum (validates bearer token)
→ Controller
```
For portal-token routes (artists / suppliers / press):
```
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` | SPA origin |
| `FRONTEND_PORTAL_URL` | `.env` / `config/app.php` | Legacy — set to the same value as `FRONTEND_APP_URL` post-WS-3. Still consumed by outbound-email controllers (password-reset, email-change, person-create) for per-app URL maps; refactor tracked as `TECH-FRONTEND-URL-CONSOLIDATE`. |
| `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 |
---
## 10. Impersonation
### 10.1 Overview
Platform admins (`super_admin`) can impersonate other users to investigate issues. The system uses a **header-based approach** — the admin's own httpOnly cookie session is never modified. Instead, the frontend sends an `X-Impersonate-User` header, and a middleware swaps the auth context per-request.
### 10.2 Security Controls
| Control | Implementation |
|---------|---------------|
| **MFA at start** | Admin must provide a valid TOTP, email, or backup code to begin impersonation |
| **Admin MFA required** | Admin's MFA must be enabled (`mfa_enabled = true`) |
| **No super_admin targets** | Cannot impersonate another super_admin |
| **No nesting** | Cannot start a second impersonation while one is active |
| **No double-impersonation** | Cannot impersonate a user already being impersonated by another admin |
| **IP pinning** | If the admin's IP changes mid-session, the session is terminated |
| **Sensitive route blocking** | Auth, MFA, password, email, profile, and impersonation routes are blocked during impersonation |
| **Sliding TTL** | Sessions auto-expire after 60 minutes; each request extends the window |
| **Immutable audit** | Every session is recorded in `impersonation_sessions` with no soft deletes |
### 10.3 Flow
```
Admin (with httpOnly cookie) API
│ │
│ POST /admin/impersonate/{user} │
│ { reason, mfa_code, mfa_method } │
│ ──────────────────────────────────────────►│
│ │ ── Verify admin is super_admin
│ │ ── Verify admin has MFA enabled
│ │ ── Verify target is not super_admin
│ │ ── Check no active session (admin or target)
│ │ ── Verify MFA code
│ │ ── Create ImpersonationSession (DB)
│ │ ── Store session ID in cache (60 min TTL)
│ ◄── session + user data ─────────────────│
│ │
│ GET /any-route │
│ Cookie: crewli_app_token=... │
│ X-Impersonate-User: {target_user_id} │
│ ──────────────────────────────────────────►│
│ │ ── CookieBearerToken: inject admin auth
│ │ ── auth:sanctum: validate admin token
│ │ ── HandleImpersonation middleware:
│ │ 1. Read X-Impersonate-User header
│ │ 2. Check sensitive route block list
│ │ 3. Validate cache session (admin+target)
│ │ 4. Check IP matches session
│ │ 5. Extend sliding TTL
│ │ 6. Store impersonator in request attributes
│ │ 7. Swap auth: setUser(targetUser)
│ │ 8. Increment actions_count
│ │ ── Controller sees targetUser as auth user
│ ◄── response (as target user) ───────────│
│ │
│ POST /admin/stop-impersonation │
│ (NO X-Impersonate-User header) │
│ ──────────────────────────────────────────►│
│ │ ── Admin auth from cookie
│ │ ── End session (DB + cache)
│ ◄── admin user data ─────────────────────│
```
### 10.4 Blocked Routes During Impersonation
The `HandleImpersonation` middleware blocks these route prefixes when the `X-Impersonate-User` header is present:
- `auth/password` — password management
- `auth/logout` — would affect admin's session
- `auth/mfa` — MFA setup/verify/status
- `auth/trusted-devices` — device trust management
- `me/profile` — profile updates
- `me/change-password` — password changes
- `me/change-email` — email changes
- `admin/impersonate` — no nesting via API
- `verify-email-change` — email verification
### 10.5 Activity Log Integration
During an active impersonation session, `AppServiceProvider` hooks into `Activity::saving()` and automatically adds:
- `impersonated_by.user_id` — the admin's user ID
- `impersonated_by.name` — the admin's full name
- `impersonated_by.email` — the admin's email
- `impersonation_session_id` — the session ID
This applies to **all** activity log entries, not just impersonation-specific events.
### 10.6 Database Table
**`impersonation_sessions`** — immutable audit table (no soft deletes)
| Column | Type | Notes |
|--------|------|-------|
| `id` | ULID | PK |
| `admin_id` | ULID FK | → users |
| `target_user_id` | ULID FK | → users |
| `reason` | string | Admin-provided reason for impersonation |
| `mfa_method` | string(20) | Which MFA method was used to verify |
| `ip_address` | string(45) | Admin's IP at session start |
| `user_agent` | text nullable | Admin's user agent |
| `started_at` | timestamp | Session start time |
| `ended_at` | timestamp nullable | NULL = still active |
| `expires_at` | timestamp | Auto-expiry time (sliding, 60 min) |
| `end_reason` | string(50) nullable | manual, expired, ip_changed, admin_kill_all |
| `actions_count` | unsigned int | Number of API requests made during session |
**Indexes:** `(admin_id, ended_at)`, `(target_user_id, ended_at)`, `(started_at)`
### 10.7 Key Files
| File | Purpose |
|------|---------|
| `app/Services/ImpersonationService.php` | Session lifecycle, MFA verification, cache management |
| `app/Http/Middleware/HandleImpersonation.php` | Per-request header validation, auth swap, route blocking |
| `app/Http/Controllers/Api/V1/Admin/AdminImpersonationController.php` | Start, stop, status, send-mfa-code endpoints |
| `app/Http/Requests/Admin/StartImpersonationRequest.php` | Validation for start request |
| `app/Models/ImpersonationSession.php` | Eloquent model with `HasUlids`, `scopeActive()` |
| `app/Http/Resources/Admin/ImpersonationSessionResource.php` | API resource for session data |
---
## 11. History — pre-WS-3 dual-cookie architecture
Pre-WS-3 (April 2026), Crewli ran two separate SPAs (`apps/app` for organizers, `apps/portal` for crew/volunteers) and the auth layer maintained per-app cookies (`crewli_app_token`, `crewli_portal_token`) with Origin-based resolution in both `CookieBearerToken` middleware and the `SetAuthCookie` controller trait.
WS-3 PR-B (AprilMay 2026) consolidated to a single SPA workspace. PR-B2a unified the frontend stores, axios factory, route guards, and `ContextSwitcher`. PR-B2b retired the dual-cookie machinery on the server: `crewli_portal_token` is fully purged, the Origin-resolution code paths are gone, and the auth cookie is unconditional. The Portal Token-Based Flow for artists/suppliers (described in §6) is unchanged — that mechanism is independent of the cookie flow and remains the canonical way to authenticate per-token portal links.