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>
405 lines
20 KiB
Markdown
405 lines
20 KiB
Markdown
# 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 (April–May 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.
|