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>
This commit is contained in:
@@ -279,3 +279,124 @@ Platform admins (`super_admin`) can force-disable MFA for any user via `POST /ad
|
||||
| `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 |
|
||||
|
||||
Reference in New Issue
Block a user