Files
crewli/dev-docs/AUTH_ARCHITECTURE.md
bert.hausmans 4df668b5b8 feat: replace token-based impersonation with enterprise-grade header-based system
Replaces the insecure token-in-localStorage approach with a header-based
impersonation system backed by cache sessions and MFA verification.

Key changes:
- New impersonation_sessions audit table (immutable, ULID PK)
- MFA verification required to start impersonation (TOTP/email/backup)
- X-Impersonate-User header + HandleImpersonation middleware
- Per-request auth context swap (admin session never modified)
- IP pinning, sensitive route blocking, no nesting, sliding 60-min TTL
- Activity log auto-tagged with impersonated_by during sessions
- Frontend: sessionStorage, BroadcastChannel sync, countdown timer
- ImpersonateDialog with reason + MFA verification flow
- 26 comprehensive tests covering core, middleware, audit, lifecycle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 02:42:53 +02:00

20 KiB

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.

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


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

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