security: migrate auth tokens to httpOnly cookies (hybrid bearer token approach)
Backend: - CookieBearerToken middleware reads httpOnly cookie and injects Authorization header before Sanctum validates (prepended to API middleware group) - SetAuthCookie trait provides cookie creation/expiry helpers with per-app cookie names (crewli_admin_token, crewli_app_token, crewli_portal_token) - LoginController sets token via Set-Cookie, removes it from JSON body - LogoutController expires the auth cookie on logout - AuthRefreshController (POST /auth/refresh) rotates tokens with new cookie - InvitationController accept also sets token via cookie, not JSON body - All cookies: httpOnly, SameSite=Strict, Secure (in production) Frontend (all three SPAs): - Removed all localStorage token storage (apps/app, apps/portal) - Removed all JS-readable cookie token storage (apps/admin) - Removed Authorization: Bearer header interceptors from axios - Auth stores now rely on GET /auth/me to validate httpOnly cookie - Admin app: new Pinia auth store replaces useCookie-based auth pattern - withCredentials: true ensures browser sends cookies automatically Fixes security findings A13-1 (localStorage tokens) and A13-2 (admin cookie flags). Tokens are now invisible to JavaScript. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
173
dev-docs/AUTH_ARCHITECTURE.md
Normal file
173
dev-docs/AUTH_ARCHITECTURE.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# 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. Three 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 |
|
||||
|-----|-----------|------------|---------|
|
||||
| Admin | localhost:5173 | admin.crewli.app | Super admin / platform management |
|
||||
| App | localhost:5174 | app.crewli.app | Organiser dashboard |
|
||||
| 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 |
|
||||
|-----|-------------|--------|--------|----------|----------|---------|
|
||||
| Admin | `crewli_admin_token` | `.crewli.app` (prod) / `localhost` (dev) | Yes (prod) | Yes | Strict | 7 days |
|
||||
| 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. Checks for any of the three cookie names in the request
|
||||
2. If found, sets the `Authorization: Bearer` header on the request
|
||||
3. Sanctum's existing token validation processes the header normally
|
||||
|
||||
If an `Authorization` header is already present (e.g. from the portal token flow), the middleware skips cookie injection.
|
||||
|
||||
### 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_ADMIN_URL` | `.env` / `config/app.php` | Admin SPA origin (cookie name resolution + CORS) |
|
||||
| `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) |
|
||||
Reference in New Issue
Block a user