From 4df668b5b84d4dc0021454946746da55c8b3a450 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Thu, 16 Apr 2026 02:42:53 +0200 Subject: [PATCH] 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) --- .../V1/Admin/AdminImpersonationController.php | 93 +++- .../Http/Middleware/HandleImpersonation.php | 112 ++++ .../Admin/StartImpersonationRequest.php | 25 + .../Admin/ImpersonationSessionResource.php | 29 + api/app/Models/ImpersonationSession.php | 63 +++ api/app/Providers/AppServiceProvider.php | 20 + api/app/Services/ImpersonationService.php | 275 ++++++++-- api/bootstrap/app.php | 1 + .../factories/ImpersonationSessionFactory.php | 49 ++ ...00_create_impersonation_sessions_table.php | 37 ++ api/routes/api.php | 8 +- .../Api/V1/Admin/AdminImpersonationTest.php | 510 ++++++++++++++++-- apps/app/components.d.ts | 1 + apps/app/src/App.vue | 6 + .../components/platform/ImpersonateDialog.vue | 183 +++++++ .../platform/ImpersonationBanner.vue | 113 +++- apps/app/src/composables/api/useAdmin.ts | 24 +- apps/app/src/lib/axios.ts | 26 + apps/app/src/pages/platform/users/[id].vue | 45 +- apps/app/src/pages/platform/users/index.vue | 45 +- apps/app/src/stores/useImpersonationStore.ts | 174 ++++-- apps/app/src/types/admin.ts | 30 +- dev-docs/API.md | 69 ++- dev-docs/AUTH_ARCHITECTURE.md | 121 +++++ dev-docs/SCHEMA.md | 23 + 25 files changed, 1813 insertions(+), 269 deletions(-) create mode 100644 api/app/Http/Middleware/HandleImpersonation.php create mode 100644 api/app/Http/Requests/Admin/StartImpersonationRequest.php create mode 100644 api/app/Http/Resources/Admin/ImpersonationSessionResource.php create mode 100644 api/app/Models/ImpersonationSession.php create mode 100644 api/database/factories/ImpersonationSessionFactory.php create mode 100644 api/database/migrations/2026_04_16_100000_create_impersonation_sessions_table.php create mode 100644 apps/app/src/components/platform/ImpersonateDialog.vue diff --git a/api/app/Http/Controllers/Api/V1/Admin/AdminImpersonationController.php b/api/app/Http/Controllers/Api/V1/Admin/AdminImpersonationController.php index e09a268f..b8f0cf05 100644 --- a/api/app/Http/Controllers/Api/V1/Admin/AdminImpersonationController.php +++ b/api/app/Http/Controllers/Api/V1/Admin/AdminImpersonationController.php @@ -4,39 +4,114 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1\Admin; +use App\Enums\MfaMethod; use App\Http\Controllers\Controller; +use App\Http\Requests\Admin\StartImpersonationRequest; use App\Http\Resources\Admin\AdminUserResource; +use App\Http\Resources\Admin\ImpersonationSessionResource; use App\Models\User; use App\Services\ImpersonationService; +use App\Services\MfaService; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; final class AdminImpersonationController extends Controller { public function __construct( private readonly ImpersonationService $impersonationService, + private readonly MfaService $mfaService, ) {} - public function start(User $user): JsonResponse + /** + * Start impersonating a user. + * POST /admin/impersonate/{user} + */ + public function start(StartImpersonationRequest $request, User $user): JsonResponse { /** @var User $admin */ $admin = auth()->user(); - $result = $this->impersonationService->start($admin, $user); + + $session = $this->impersonationService->start( + admin: $admin, + targetUser: $user, + reason: $request->validated('reason'), + mfaCode: $request->validated('mfa_code'), + mfaMethod: MfaMethod::from($request->validated('mfa_method')), + ipAddress: $request->ip(), + userAgent: $request->userAgent(), + ); + + $session->load('targetUser.organisations'); return $this->success([ - 'token' => $result['token'], - 'user' => new AdminUserResource($result['user']->load('organisations')), - 'admin_id' => $result['admin_id'], + 'session' => new ImpersonationSessionResource($session), + 'user' => new AdminUserResource($session->targetUser), ]); } - public function stop(): JsonResponse + /** + * Stop impersonation. + * POST /admin/stop-impersonation + * Called by the admin (without X-Impersonate-User header). + */ + public function stop(Request $request): JsonResponse { - /** @var User $currentUser */ - $currentUser = auth()->user(); - $admin = $this->impersonationService->stop($currentUser); + /** @var User $admin */ + $admin = $request->user(); + + $session = $this->impersonationService->getActiveSessionForAdmin($admin); + + if (! $session) { + return $this->error('No active impersonation session.', 400); + } + + $this->impersonationService->stop($session); return $this->success([ 'user' => new AdminUserResource($admin->load('organisations')), ]); } + + /** + * Get impersonation status. + * GET /admin/impersonate/status + */ + public function status(Request $request): JsonResponse + { + /** @var User $admin */ + $admin = $request->user(); + + $session = $this->impersonationService->getActiveSessionForAdmin($admin); + + if (! $session) { + return $this->success([ + 'active' => false, + ]); + } + + $session->load('targetUser'); + + return $this->success([ + 'active' => true, + 'session' => new ImpersonationSessionResource($session), + ]); + } + + /** + * Send MFA email code for impersonation verification. + * POST /admin/impersonate/send-mfa-code + */ + public function sendMfaCode(Request $request): JsonResponse + { + /** @var User $admin */ + $admin = $request->user(); + + if (! $admin->mfa_enabled) { + return $this->error('MFA is not enabled.', 403); + } + + $this->mfaService->sendEmailCode($admin); + + return $this->success(null, 'Verification code sent.'); + } } diff --git a/api/app/Http/Middleware/HandleImpersonation.php b/api/app/Http/Middleware/HandleImpersonation.php new file mode 100644 index 00000000..fe4154d1 --- /dev/null +++ b/api/app/Http/Middleware/HandleImpersonation.php @@ -0,0 +1,112 @@ +header('X-Impersonate-User'); + + if (! $targetUserId) { + return $next($request); + } + + /** @var User|null $admin */ + $admin = $request->user(); + + if (! $admin) { + return response()->json(['message' => 'Authentication required.'], 401); + } + + // Block sensitive routes during impersonation + if ($this->isSensitiveRoute($request)) { + return response()->json([ + 'message' => 'This action is not allowed during impersonation.', + ], 403); + } + + // Validate impersonation session via Redis + $session = $this->impersonationService->validateRequest( + $admin->id, + $targetUserId, + $request->ip(), + ); + + if (! $session) { + return response()->json([ + 'message' => 'Impersonation session is invalid or has expired.', + 'impersonation_ended' => true, + ], 403); + } + + // Load the target user + $targetUser = User::find($targetUserId); + + if (! $targetUser) { + return response()->json(['message' => 'Target user not found.'], 404); + } + + // Store impersonation context in request attributes + $request->attributes->set('impersonator', $admin); + $request->attributes->set('impersonation_session', $session); + + // Swap auth context — the rest of the request sees the target user + app('auth')->setUser($targetUser); + + // Tag all log entries with impersonation context + Log::shareContext([ + 'impersonated_by' => $admin->id, + 'impersonation_session_id' => $session->id, + ]); + + // Increment actions count + $this->impersonationService->incrementActionsCount($session); + + return $next($request); + } + + private function isSensitiveRoute(Request $request): bool + { + // Get path relative to API prefix (strip api/v1/) + $path = $request->path(); + $path = preg_replace('#^api/v1/#', '', $path); + + foreach (self::SENSITIVE_ROUTE_PREFIXES as $prefix) { + if (str_starts_with($path, $prefix)) { + return true; + } + } + + return false; + } +} diff --git a/api/app/Http/Requests/Admin/StartImpersonationRequest.php b/api/app/Http/Requests/Admin/StartImpersonationRequest.php new file mode 100644 index 00000000..19a0cbbd --- /dev/null +++ b/api/app/Http/Requests/Admin/StartImpersonationRequest.php @@ -0,0 +1,25 @@ + */ + public function rules(): array + { + return [ + 'reason' => ['required', 'string', 'min:5', 'max:500'], + 'mfa_code' => ['required', 'string'], + 'mfa_method' => ['required', 'string', 'in:totp,email,backup_code'], + ]; + } +} diff --git a/api/app/Http/Resources/Admin/ImpersonationSessionResource.php b/api/app/Http/Resources/Admin/ImpersonationSessionResource.php new file mode 100644 index 00000000..c20e9de8 --- /dev/null +++ b/api/app/Http/Resources/Admin/ImpersonationSessionResource.php @@ -0,0 +1,29 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'admin_id' => $this->admin_id, + 'target_user_id' => $this->target_user_id, + 'target_user' => new AdminUserResource($this->whenLoaded('targetUser')), + 'reason' => $this->reason, + 'mfa_method' => $this->mfa_method, + 'started_at' => $this->started_at?->toIso8601String(), + 'expires_at' => $this->expires_at?->toIso8601String(), + 'ended_at' => $this->ended_at?->toIso8601String(), + 'end_reason' => $this->end_reason, + 'actions_count' => $this->actions_count, + ]; + } +} diff --git a/api/app/Models/ImpersonationSession.php b/api/app/Models/ImpersonationSession.php new file mode 100644 index 00000000..9a7ff397 --- /dev/null +++ b/api/app/Models/ImpersonationSession.php @@ -0,0 +1,63 @@ + 'datetime', + 'ended_at' => 'datetime', + 'expires_at' => 'datetime', + 'actions_count' => 'integer', + ]; + } + + // ─── Relations ─── + + public function admin(): BelongsTo + { + return $this->belongsTo(User::class, 'admin_id'); + } + + public function targetUser(): BelongsTo + { + return $this->belongsTo(User::class, 'target_user_id'); + } + + // ─── Scopes ─── + + public function scopeActive(Builder $query): Builder + { + return $query->whereNull('ended_at') + ->where('expires_at', '>', now()); + } +} diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index c005ae2a..794c7a89 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -8,6 +8,7 @@ use App\Models\Person; use App\Observers\PersonObserver; use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Support\ServiceProvider; +use Spatie\Activitylog\Models\Activity; class AppServiceProvider extends ServiceProvider { @@ -23,5 +24,24 @@ class AppServiceProvider extends ServiceProvider ResetPassword::createUrlUsing(function ($user, string $token) { return config('crewli.portal_url') . '/wachtwoord-resetten?token=' . $token . '&email=' . urlencode($user->email); }); + + // Tag activity log entries with impersonation context + Activity::saving(function (Activity $activity) { + $request = request(); + + $impersonator = $request->attributes->get('impersonator'); + $session = $request->attributes->get('impersonation_session'); + + if ($impersonator && $session) { + $properties = $activity->properties?->toArray() ?? []; + $properties['impersonated_by'] = [ + 'user_id' => $impersonator->id, + 'name' => $impersonator->full_name, + 'email' => $impersonator->email, + ]; + $properties['impersonation_session_id'] = $session->id; + $activity->properties = collect($properties); + } + }); } } diff --git a/api/app/Services/ImpersonationService.php b/api/app/Services/ImpersonationService.php index 523454f8..5067dc74 100644 --- a/api/app/Services/ImpersonationService.php +++ b/api/app/Services/ImpersonationService.php @@ -4,89 +4,280 @@ declare(strict_types=1); namespace App\Services; +use App\Enums\MfaMethod; +use App\Models\ImpersonationSession; +use App\Models\MfaBackupCode; +use App\Models\MfaEmailCode; use App\Models\User; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Log; +use PragmaRX\Google2FA\Google2FA; final class ImpersonationService { + private const REDIS_PREFIX = 'impersonation:'; + private const SESSION_TTL_SECONDS = 3600; // 60 minutes + + public function __construct( + private readonly Google2FA $google2fa, + ) {} + /** - * Start impersonating a target user. - * - * @return array{token: string, user: User, admin_id: string} + * Start an impersonation session. + * Requires valid MFA code from the admin. */ - public function start(User $admin, User $targetUser): array - { + public function start( + User $admin, + User $targetUser, + string $reason, + string $mfaCode, + MfaMethod $mfaMethod, + string $ipAddress, + ?string $userAgent = null, + ): ImpersonationSession { + // ─── Guards ─── + if (! $admin->hasRole('super_admin')) { abort(403, 'Only super admins can impersonate users.'); } + if (! $admin->mfa_enabled) { + abort(403, 'MFA must be enabled to impersonate users.'); + } + if ($targetUser->hasRole('super_admin')) { abort(403, 'Cannot impersonate another super admin.'); } - $tokenName = 'impersonation-by-' . $admin->id; - $newToken = $targetUser->createToken($tokenName); + // Cannot nest: admin already impersonating someone + if ($this->getActiveSessionForAdmin($admin)) { + abort(403, 'You already have an active impersonation session.'); + } - Cache::put( - "impersonation:{$newToken->accessToken->id}", - $admin->id, - now()->addHours(4), - ); + // Cannot impersonate a user already being impersonated + if ($this->getActiveSessionForTarget($targetUser)) { + abort(403, 'This user is already being impersonated.'); + } + // ─── MFA Verification ─── + + $this->verifyMfaCode($admin, $mfaCode, $mfaMethod); + + // ─── Create session ─── + + $session = ImpersonationSession::create([ + 'admin_id' => $admin->id, + 'target_user_id' => $targetUser->id, + 'reason' => $reason, + 'mfa_method' => $mfaMethod->value, + 'ip_address' => $ipAddress, + 'user_agent' => $userAgent, + 'started_at' => now(), + 'expires_at' => now()->addSeconds(self::SESSION_TTL_SECONDS), + ]); + + // Store in Redis for fast middleware lookups + $this->storeCacheSession($session); + + // Activity log activity('admin') ->causedBy($admin) ->performedOn($targetUser) ->event('admin.impersonation.started') ->withProperties([ + 'session_id' => $session->id, 'admin_id' => $admin->id, 'target_user_id' => $targetUser->id, + 'reason' => $reason, + 'mfa_method' => $mfaMethod->value, ]) ->log('Started impersonating user ' . $targetUser->full_name); - return [ - 'token' => $newToken->plainTextToken, - 'user' => $targetUser, - 'admin_id' => $admin->id, - ]; + return $session; } /** - * Stop impersonation and return the original admin. + * Stop an impersonation session. */ - public function stop(User $currentUser): User + public function stop(ImpersonationSession $session, string $endReason = 'manual'): void { - $currentToken = $currentUser->currentAccessToken(); + $session->update([ + 'ended_at' => now(), + 'end_reason' => $endReason, + ]); - if (! $currentToken || ! str_starts_with($currentToken->name, 'impersonation-by-')) { - abort(400, 'No active impersonation session.'); - } - - $adminId = Cache::get("impersonation:{$currentToken->id}"); - - $admin = $adminId ? User::find($adminId) : null; - - if (! $admin) { - // Fallback: extract admin ID from token name - $admin = User::find(str_replace('impersonation-by-', '', $currentToken->name)); - } + $this->clearCacheSession($session->admin_id, $session->target_user_id); activity('admin') - ->causedBy($admin ?? $currentUser) - ->performedOn($currentUser) + ->causedBy(User::find($session->admin_id)) + ->performedOn(User::find($session->target_user_id)) ->event('admin.impersonation.stopped') ->withProperties([ - 'admin_id' => $admin?->id, - 'impersonated_user_id' => $currentUser->id, + 'session_id' => $session->id, + 'end_reason' => $endReason, + 'actions_count' => $session->actions_count, ]) - ->log('Stopped impersonating user ' . $currentUser->full_name); + ->log('Stopped impersonating user (reason: ' . $endReason . ')'); + } - Cache::forget("impersonation:{$currentToken->id}"); - $currentToken->delete(); + /** + * Get active session for an admin user. + */ + public function getActiveSessionForAdmin(User $admin): ?ImpersonationSession + { + return ImpersonationSession::where('admin_id', $admin->id) + ->active() + ->first(); + } - if (! $admin) { - abort(400, 'Could not resolve original admin session.'); + /** + * Get active session for a target user. + */ + public function getActiveSessionForTarget(User $targetUser): ?ImpersonationSession + { + return ImpersonationSession::where('target_user_id', $targetUser->id) + ->active() + ->first(); + } + + /** + * Check if a given admin is currently impersonating a specific user. + * Used by middleware for fast validation via Redis. + */ + public function validateRequest(string $adminId, string $targetUserId, string $ipAddress): ?ImpersonationSession + { + $cacheKey = self::REDIS_PREFIX . $adminId . ':' . $targetUserId; + $sessionId = Cache::get($cacheKey); + + if (! $sessionId) { + return null; } - return $admin; + $session = ImpersonationSession::find($sessionId); + + if (! $session || $session->ended_at !== null) { + $this->clearCacheSession($adminId, $targetUserId); + + return null; + } + + // IP pinning check + if ($session->ip_address !== $ipAddress) { + Log::warning('Impersonation IP mismatch', [ + 'session_id' => $session->id, + 'expected_ip' => $session->ip_address, + 'actual_ip' => $ipAddress, + ]); + + $this->stop($session, 'ip_changed'); + + return null; + } + + // Sliding TTL: extend cache and DB expiry + $newExpiry = now()->addSeconds(self::SESSION_TTL_SECONDS); + Cache::put($cacheKey, $sessionId, $newExpiry); + $session->update(['expires_at' => $newExpiry]); + + return $session; + } + + /** + * Increment the actions count for a session. + */ + public function incrementActionsCount(ImpersonationSession $session): void + { + $session->increment('actions_count'); + } + + /** + * Kill all active impersonation sessions (emergency). + */ + public function killAll(): int + { + $sessions = ImpersonationSession::active()->get(); + $count = 0; + + foreach ($sessions as $session) { + $this->stop($session, 'admin_kill_all'); + $count++; + } + + return $count; + } + + // ─── Private helpers ─── + + private function verifyMfaCode(User $user, string $code, MfaMethod $method): void + { + match ($method) { + MfaMethod::TOTP => $this->verifyTotpCode($user, $code), + MfaMethod::EMAIL => $this->verifyEmailCode($user, $code), + MfaMethod::BACKUP_CODE => $this->verifyBackupCode($user, $code), + }; + } + + private function verifyTotpCode(User $user, string $code): void + { + if (! $user->mfa_secret) { + abort(403, 'TOTP is not configured.'); + } + + $secret = decrypt($user->mfa_secret); + + if (! $this->google2fa->verifyKey($secret, $code, 1)) { + abort(403, 'Invalid MFA code.'); + } + } + + private function verifyEmailCode(User $user, string $code): void + { + $record = MfaEmailCode::where('user_id', $user->id) + ->where('code', $code) + ->where('used', false) + ->where('expires_at', '>', now()) + ->first(); + + if (! $record) { + abort(403, 'Invalid or expired email code.'); + } + + $record->update(['used' => true]); + } + + private function verifyBackupCode(User $user, string $code): void + { + $normalizedCode = strtoupper(str_replace([' ', '-'], '', $code)); + + $backupCodes = MfaBackupCode::where('user_id', $user->id) + ->where('used', false) + ->get(); + + foreach ($backupCodes as $backupCode) { + if (Hash::check($code, $backupCode->code_hash) || + Hash::check($normalizedCode, $backupCode->code_hash)) { + $backupCode->update([ + 'used' => true, + 'used_at' => now(), + ]); + + return; + } + } + + abort(403, 'Invalid backup code.'); + } + + private function storeCacheSession(ImpersonationSession $session): void + { + $cacheKey = self::REDIS_PREFIX . $session->admin_id . ':' . $session->target_user_id; + Cache::put($cacheKey, $session->id, now()->addSeconds(self::SESSION_TTL_SECONDS)); + } + + private function clearCacheSession(string $adminId, string $targetUserId): void + { + $cacheKey = self::REDIS_PREFIX . $adminId . ':' . $targetUserId; + Cache::forget($cacheKey); } } diff --git a/api/bootstrap/app.php b/api/bootstrap/app.php index a0807971..74648dda 100644 --- a/api/bootstrap/app.php +++ b/api/bootstrap/app.php @@ -35,6 +35,7 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->alias([ 'portal.token' => \App\Http\Middleware\PortalTokenMiddleware::class, 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, + 'impersonation' => \App\Http\Middleware\HandleImpersonation::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/api/database/factories/ImpersonationSessionFactory.php b/api/database/factories/ImpersonationSessionFactory.php new file mode 100644 index 00000000..ecb1b2fb --- /dev/null +++ b/api/database/factories/ImpersonationSessionFactory.php @@ -0,0 +1,49 @@ + */ +final class ImpersonationSessionFactory extends Factory +{ + protected $model = ImpersonationSession::class; + + /** @return array */ + public function definition(): array + { + return [ + 'admin_id' => User::factory(), + 'target_user_id' => User::factory(), + 'reason' => fake()->sentence(), + 'mfa_method' => 'totp', + 'ip_address' => fake()->ipv4(), + 'user_agent' => fake()->userAgent(), + 'started_at' => now(), + 'expires_at' => now()->addMinutes(60), + 'actions_count' => 0, + ]; + } + + public function ended(string $reason = 'manual'): static + { + return $this->state(fn () => [ + 'ended_at' => now(), + 'end_reason' => $reason, + ]); + } + + public function expired(): static + { + return $this->state(fn () => [ + 'started_at' => now()->subHours(2), + 'expires_at' => now()->subHour(), + 'ended_at' => now()->subHour(), + 'end_reason' => 'expired', + ]); + } +} diff --git a/api/database/migrations/2026_04_16_100000_create_impersonation_sessions_table.php b/api/database/migrations/2026_04_16_100000_create_impersonation_sessions_table.php new file mode 100644 index 00000000..ebdf146f --- /dev/null +++ b/api/database/migrations/2026_04_16_100000_create_impersonation_sessions_table.php @@ -0,0 +1,37 @@ +ulid('id')->primary(); + $table->foreignUlid('admin_id')->constrained('users')->cascadeOnDelete(); + $table->foreignUlid('target_user_id')->constrained('users')->cascadeOnDelete(); + $table->string('reason'); + $table->string('mfa_method', 20); + $table->string('ip_address', 45); + $table->text('user_agent')->nullable(); + $table->timestamp('started_at'); + $table->timestamp('ended_at')->nullable(); + $table->timestamp('expires_at'); + $table->string('end_reason', 50)->nullable(); + $table->unsignedInteger('actions_count')->default(0); + + $table->index(['admin_id', 'ended_at']); + $table->index(['target_user_id', 'ended_at']); + $table->index('started_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('impersonation_sessions'); + } +}; diff --git a/api/routes/api.php b/api/routes/api.php index e9f51369..433e9486 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -95,7 +95,7 @@ Route::post('portal/token-auth', [PortalTokenController::class, 'auth'])->middle // Platform Admin routes Route::prefix('admin') - ->middleware(['auth:sanctum', 'role:super_admin']) + ->middleware(['auth:sanctum', 'impersonation', 'role:super_admin']) ->name('admin.') ->group(function () { // Organisations @@ -115,12 +115,14 @@ Route::prefix('admin') // Activity log Route::get('activity-log', [AdminActivityLogController::class, 'index']); - // Impersonation (start) + // Impersonation — specific routes before wildcard + Route::get('impersonate/status', [AdminImpersonationController::class, 'status']); + Route::post('impersonate/send-mfa-code', [AdminImpersonationController::class, 'sendMfaCode']); Route::post('impersonate/{user}', [AdminImpersonationController::class, 'start']); }); // Protected routes -Route::middleware('auth:sanctum')->group(function () { +Route::middleware(['auth:sanctum', 'impersonation'])->group(function () { // Impersonation (stop — accessible by impersonated user, not just super_admin) Route::post('admin/stop-impersonation', [AdminImpersonationController::class, 'stop']); diff --git a/api/tests/Feature/Api/V1/Admin/AdminImpersonationTest.php b/api/tests/Feature/Api/V1/Admin/AdminImpersonationTest.php index a22e341b..ce1822f2 100644 --- a/api/tests/Feature/Api/V1/Admin/AdminImpersonationTest.php +++ b/api/tests/Feature/Api/V1/Admin/AdminImpersonationTest.php @@ -4,10 +4,18 @@ declare(strict_types=1); namespace Tests\Feature\Api\V1\Admin; +use App\Enums\MfaMethod; +use App\Models\ImpersonationSession; +use App\Models\MfaBackupCode; +use App\Models\MfaEmailCode; use App\Models\User; +use App\Services\ImpersonationService; use Database\Seeders\RoleSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Hash; use Laravel\Sanctum\Sanctum; +use PragmaRX\Google2FA\Google2FA; use Spatie\Activitylog\Models\Activity; use Tests\TestCase; @@ -18,47 +26,192 @@ class AdminImpersonationTest extends TestCase private User $superAdmin; private User $targetUser; private User $otherSuperAdmin; + private string $totpSecret; protected function setUp(): void { parent::setUp(); $this->seed(RoleSeeder::class); - $this->superAdmin = User::factory()->create(); + // Create super admin with MFA enabled (TOTP) + $this->totpSecret = (new Google2FA)->generateSecretKey(32); + $this->superAdmin = User::factory()->create([ + 'mfa_enabled' => true, + 'mfa_method' => MfaMethod::TOTP->value, + 'mfa_secret' => encrypt($this->totpSecret), + 'mfa_confirmed_at' => now(), + ]); $this->superAdmin->assignRole('super_admin'); $this->targetUser = User::factory()->create(); - $this->otherSuperAdmin = User::factory()->create(); + $this->otherSuperAdmin = User::factory()->create([ + 'mfa_enabled' => true, + 'mfa_method' => MfaMethod::TOTP->value, + 'mfa_secret' => encrypt((new Google2FA)->generateSecretKey(32)), + 'mfa_confirmed_at' => now(), + ]); $this->otherSuperAdmin->assignRole('super_admin'); } - // ─── Start ─────────────────────────────────────────────── + private function validTotpCode(): string + { + return (new Google2FA)->getCurrentOtp($this->totpSecret); + } - public function test_start_creates_token_for_target_user(): void + private function startPayload(array $overrides = []): array + { + return array_merge([ + 'reason' => 'Investigating user issue', + 'mfa_code' => $this->validTotpCode(), + 'mfa_method' => 'totp', + ], $overrides); + } + + // ─── Core: Start ──────────────────────────────────────────── + + public function test_start_creates_redis_and_db_session(): void { Sanctum::actingAs($this->superAdmin); - $response = $this->postJson("/api/v1/admin/impersonate/{$this->targetUser->id}"); + $response = $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + $this->startPayload(), + ); $response->assertOk(); $response->assertJsonStructure([ - 'data' => ['token', 'user' => ['id', 'email'], 'admin_id'], + 'data' => [ + 'session' => ['id', 'admin_id', 'target_user_id', 'reason', 'mfa_method', 'started_at', 'expires_at'], + 'user' => ['id', 'email'], + ], ]); $response->assertJsonPath('data.user.id', $this->targetUser->id); - $response->assertJsonPath('data.admin_id', $this->superAdmin->id); + $response->assertJsonPath('data.session.admin_id', $this->superAdmin->id); - $this->assertDatabaseHas('personal_access_tokens', [ - 'tokenable_id' => $this->targetUser->id, - 'name' => 'impersonation-by-' . $this->superAdmin->id, + $this->assertDatabaseHas('impersonation_sessions', [ + 'admin_id' => $this->superAdmin->id, + 'target_user_id' => $this->targetUser->id, + 'reason' => 'Investigating user issue', + 'mfa_method' => 'totp', ]); + + // Verify cache entry + $session = ImpersonationSession::first(); + $cacheKey = "impersonation:{$this->superAdmin->id}:{$this->targetUser->id}"; + $this->assertEquals($session->id, Cache::get($cacheKey)); + } + + public function test_start_requires_reason(): void + { + Sanctum::actingAs($this->superAdmin); + + $response = $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + $this->startPayload(['reason' => '']), + ); + + $response->assertUnprocessable(); + } + + public function test_start_requires_reason_minimum_length(): void + { + Sanctum::actingAs($this->superAdmin); + + $response = $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + $this->startPayload(['reason' => 'ab']), + ); + + $response->assertUnprocessable(); + } + + public function test_start_requires_valid_mfa_code(): void + { + Sanctum::actingAs($this->superAdmin); + + $response = $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + $this->startPayload(['mfa_code' => '000000']), + ); + + $response->assertForbidden(); + } + + public function test_start_with_email_code(): void + { + Sanctum::actingAs($this->superAdmin); + + // Create a valid email code for the admin + MfaEmailCode::create([ + 'user_id' => $this->superAdmin->id, + 'code' => '123456', + 'expires_at' => now()->addMinutes(10), + ]); + + $response = $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + $this->startPayload([ + 'mfa_code' => '123456', + 'mfa_method' => 'email', + ]), + ); + + $response->assertOk(); + + $this->assertDatabaseHas('impersonation_sessions', [ + 'mfa_method' => 'email', + ]); + } + + public function test_start_with_backup_code(): void + { + Sanctum::actingAs($this->superAdmin); + + $plainCode = 'ABCD-EFGH'; + MfaBackupCode::create([ + 'user_id' => $this->superAdmin->id, + 'code_hash' => Hash::make($plainCode), + ]); + + $response = $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + $this->startPayload([ + 'mfa_code' => $plainCode, + 'mfa_method' => 'backup_code', + ]), + ); + + $response->assertOk(); + + $this->assertDatabaseHas('impersonation_sessions', [ + 'mfa_method' => 'backup_code', + ]); + } + + public function test_start_requires_admin_mfa_enabled(): void + { + $adminNoMfa = User::factory()->create(['mfa_enabled' => false]); + $adminNoMfa->assignRole('super_admin'); + Sanctum::actingAs($adminNoMfa); + + $response = $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + $this->startPayload(), + ); + + $response->assertForbidden(); + $response->assertJsonFragment(['message' => 'MFA must be enabled to impersonate users.']); } public function test_start_denied_for_non_super_admin(): void { Sanctum::actingAs($this->targetUser); - $response = $this->postJson("/api/v1/admin/impersonate/{$this->targetUser->id}"); + $response = $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + $this->startPayload(), + ); $response->assertForbidden(); } @@ -67,58 +220,331 @@ class AdminImpersonationTest extends TestCase { Sanctum::actingAs($this->superAdmin); - $response = $this->postJson("/api/v1/admin/impersonate/{$this->otherSuperAdmin->id}"); + $response = $this->postJson( + "/api/v1/admin/impersonate/{$this->otherSuperAdmin->id}", + $this->startPayload(), + ); $response->assertForbidden(); + $response->assertJsonFragment(['message' => 'Cannot impersonate another super admin.']); } - // ─── Stop ──────────────────────────────────────────────── - - public function test_stop_deletes_impersonation_token(): void + public function test_start_denied_when_nesting(): void { - // Start impersonation Sanctum::actingAs($this->superAdmin); - $startResponse = $this->postJson("/api/v1/admin/impersonate/{$this->targetUser->id}"); - $token = $startResponse->json('data.token'); - // Reset auth state so the Bearer token takes effect - $this->app['auth']->forgetGuards(); + // Start first session + $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + $this->startPayload(), + )->assertOk(); - $response = $this->withHeader('Authorization', "Bearer {$token}") - ->postJson('/api/v1/admin/stop-impersonation'); + // Try to start another session + $anotherUser = User::factory()->create(); + $response = $this->postJson( + "/api/v1/admin/impersonate/{$anotherUser->id}", + $this->startPayload(['mfa_code' => $this->validTotpCode()]), + ); + + $response->assertForbidden(); + $response->assertJsonFragment(['message' => 'You already have an active impersonation session.']); + } + + public function test_start_denied_when_target_already_impersonated(): void + { + // First admin impersonates target + Sanctum::actingAs($this->superAdmin); + $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + $this->startPayload(), + )->assertOk(); + + // Second admin tries to impersonate same target + $admin2Secret = (new Google2FA)->generateSecretKey(32); + $admin2 = User::factory()->create([ + 'mfa_enabled' => true, + 'mfa_method' => MfaMethod::TOTP->value, + 'mfa_secret' => encrypt($admin2Secret), + 'mfa_confirmed_at' => now(), + ]); + $admin2->assignRole('super_admin'); + Sanctum::actingAs($admin2); + + $response = $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + [ + 'reason' => 'Also investigating', + 'mfa_code' => (new Google2FA)->getCurrentOtp($admin2Secret), + 'mfa_method' => 'totp', + ], + ); + + $response->assertForbidden(); + $response->assertJsonFragment(['message' => 'This user is already being impersonated.']); + } + + public function test_session_records_mfa_method(): void + { + Sanctum::actingAs($this->superAdmin); + + $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + $this->startPayload(), + )->assertOk(); + + $this->assertDatabaseHas('impersonation_sessions', [ + 'admin_id' => $this->superAdmin->id, + 'mfa_method' => 'totp', + ]); + } + + // ─── Core: Stop ───────────────────────────────────────────── + + public function test_stop_clears_cache_and_updates_db(): void + { + Sanctum::actingAs($this->superAdmin); + + $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + $this->startPayload(), + )->assertOk(); + + $response = $this->postJson('/api/v1/admin/stop-impersonation'); $response->assertOk(); $response->assertJsonPath('data.user.id', $this->superAdmin->id); - $this->assertDatabaseMissing('personal_access_tokens', [ - 'tokenable_id' => $this->targetUser->id, - 'name' => 'impersonation-by-' . $this->superAdmin->id, - ]); + // DB session should be ended + $session = ImpersonationSession::first(); + $this->assertNotNull($session->ended_at); + $this->assertEquals('manual', $session->end_reason); + + // Cache should be cleared + $cacheKey = "impersonation:{$this->superAdmin->id}:{$this->targetUser->id}"; + $this->assertNull(Cache::get($cacheKey)); } - // ─── Activity Log ──────────────────────────────────────── - - public function test_activity_log_records_start_and_stop(): void + public function test_stop_without_session_returns_400(): void { Sanctum::actingAs($this->superAdmin); - $startResponse = $this->postJson("/api/v1/admin/impersonate/{$this->targetUser->id}"); - $token = $startResponse->json('data.token'); - $this->assertDatabaseHas('activity_log', [ - 'event' => 'admin.impersonation.started', - 'causer_id' => $this->superAdmin->id, - 'subject_id' => $this->targetUser->id, - ]); + $response = $this->postJson('/api/v1/admin/stop-impersonation'); - // Reset auth state so the Bearer token takes effect + $response->assertStatus(400); + } + + // ─── Middleware ────────────────────────────────────────────── + + public function test_header_swaps_auth_context(): void + { + Sanctum::actingAs($this->superAdmin); + + // Start impersonation + $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + $this->startPayload(), + )->assertOk(); + + // Make a request with the impersonation header + $response = $this->withHeader('X-Impersonate-User', $this->targetUser->id) + ->getJson('/api/v1/auth/me'); + + $response->assertOk(); + $response->assertJsonPath('data.id', $this->targetUser->id); + } + + public function test_no_header_returns_normal_auth(): void + { + Sanctum::actingAs($this->superAdmin); + + $response = $this->getJson('/api/v1/auth/me'); + + $response->assertOk(); + $response->assertJsonPath('data.id', $this->superAdmin->id); + } + + public function test_invalid_session_returns_403(): void + { + Sanctum::actingAs($this->superAdmin); + + // Send header without an active session + $response = $this->withHeader('X-Impersonate-User', $this->targetUser->id) + ->getJson('/api/v1/auth/me'); + + $response->assertForbidden(); + $response->assertJson(['impersonation_ended' => true]); + } + + public function test_sensitive_routes_blocked_during_impersonation(): void + { + Sanctum::actingAs($this->superAdmin); + + $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + $this->startPayload(), + )->assertOk(); + + $sensitiveRoutes = [ + ['POST', '/api/v1/me/change-password'], + ['POST', '/api/v1/me/change-email'], + ['PUT', '/api/v1/me/profile'], + ['POST', '/api/v1/auth/mfa/setup/totp'], + ['GET', '/api/v1/auth/mfa/status'], + ['GET', '/api/v1/auth/trusted-devices'], + ]; + + foreach ($sensitiveRoutes as [$method, $url]) { + $response = $this->withHeader('X-Impersonate-User', $this->targetUser->id) + ->json($method, $url); + + $this->assertEquals(403, $response->status(), "Route {$method} {$url} should be blocked"); + $this->assertEquals( + 'This action is not allowed during impersonation.', + $response->json('message'), + "Route {$method} {$url} should have correct message", + ); + } + } + + public function test_ip_change_terminates_session(): void + { + Sanctum::actingAs($this->superAdmin); + + // Start impersonation from IP 127.0.0.1 (default in tests) + $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + $this->startPayload(), + )->assertOk(); + + // Make request from different IP + $response = $this->withHeader('X-Impersonate-User', $this->targetUser->id) + ->withServerVariables(['REMOTE_ADDR' => '192.168.1.100']) + ->getJson('/api/v1/auth/me'); + + $response->assertForbidden(); + + // Session should be ended with ip_changed reason + $session = ImpersonationSession::first(); + $this->assertNotNull($session->ended_at); + $this->assertEquals('ip_changed', $session->end_reason); + } + + // ─── Audit ────────────────────────────────────────────────── + + public function test_activity_log_includes_impersonated_by_during_session(): void + { + Sanctum::actingAs($this->superAdmin); + + $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + $this->startPayload(), + )->assertOk(); + + // Check start activity + $startActivity = Activity::where('event', 'admin.impersonation.started')->first(); + $this->assertNotNull($startActivity); + $this->assertEquals($this->superAdmin->id, $startActivity->causer_id); + $this->assertEquals($this->targetUser->id, $startActivity->subject_id); + $this->assertArrayHasKey('session_id', $startActivity->properties->toArray()); + } + + public function test_activity_log_records_stop(): void + { + Sanctum::actingAs($this->superAdmin); + + $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + $this->startPayload(), + )->assertOk(); + + $this->postJson('/api/v1/admin/stop-impersonation')->assertOk(); + + $stopActivity = Activity::where('event', 'admin.impersonation.stopped')->first(); + $this->assertNotNull($stopActivity); + $this->assertArrayHasKey('end_reason', $stopActivity->properties->toArray()); + } + + public function test_session_increments_actions_count(): void + { + Sanctum::actingAs($this->superAdmin); + + $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + $this->startPayload(), + )->assertOk(); + + // Make a request with the impersonation header + $this->withHeader('X-Impersonate-User', $this->targetUser->id) + ->getJson('/api/v1/auth/me') + ->assertOk(); + + // Reset auth guards so Sanctum::actingAs takes effect again $this->app['auth']->forgetGuards(); + Sanctum::actingAs($this->superAdmin); - $this->withHeader('Authorization', "Bearer {$token}") - ->postJson('/api/v1/admin/stop-impersonation'); + $this->withHeader('X-Impersonate-User', $this->targetUser->id) + ->getJson('/api/v1/auth/me') + ->assertOk(); - $this->assertDatabaseHas('activity_log', [ - 'event' => 'admin.impersonation.stopped', - 'subject_id' => $this->targetUser->id, + $session = ImpersonationSession::first(); + $this->assertEquals(2, $session->refresh()->actions_count); + } + + // ─── Lifecycle ────────────────────────────────────────────── + + public function test_status_returns_active_session(): void + { + Sanctum::actingAs($this->superAdmin); + + $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + $this->startPayload(), + )->assertOk(); + + $response = $this->getJson('/api/v1/admin/impersonate/status'); + + $response->assertOk(); + $response->assertJsonPath('data.active', true); + $response->assertJsonStructure([ + 'data' => [ + 'active', + 'session' => ['id', 'target_user_id', 'expires_at'], + ], ]); } + + public function test_status_returns_false_when_not_impersonating(): void + { + Sanctum::actingAs($this->superAdmin); + + $response = $this->getJson('/api/v1/admin/impersonate/status'); + + $response->assertOk(); + $response->assertJsonPath('data.active', false); + } + + public function test_send_mfa_code_sends_email(): void + { + Sanctum::actingAs($this->superAdmin); + + $response = $this->postJson('/api/v1/admin/impersonate/send-mfa-code'); + + $response->assertOk(); + + // Verify email code was created + $this->assertDatabaseHas('mfa_email_codes', [ + 'user_id' => $this->superAdmin->id, + 'used' => false, + ]); + } + + public function test_unauthenticated_request_returns_401(): void + { + $response = $this->postJson( + "/api/v1/admin/impersonate/{$this->targetUser->id}", + $this->startPayload(), + ); + + $response->assertUnauthorized(); + } } diff --git a/apps/app/components.d.ts b/apps/app/components.d.ts index 9657b754..55be0420 100644 --- a/apps/app/components.d.ts +++ b/apps/app/components.d.ts @@ -67,6 +67,7 @@ declare module 'vue' { EventMetricCards: typeof import('./src/components/events/EventMetricCards.vue')['default'] EventTabsNav: typeof import('./src/components/events/EventTabsNav.vue')['default'] I18n: typeof import('./src/@core/components/I18n.vue')['default'] + ImpersonateDialog: typeof import('./src/components/platform/ImpersonateDialog.vue')['default'] ImpersonationBanner: typeof import('./src/components/platform/ImpersonationBanner.vue')['default'] ImportFromEventDialog: typeof import('./src/components/event/ImportFromEventDialog.vue')['default'] InfoTooltip: typeof import('./src/components/common/InfoTooltip.vue')['default'] diff --git a/apps/app/src/App.vue b/apps/app/src/App.vue index e92e9cfb..866b7882 100644 --- a/apps/app/src/App.vue +++ b/apps/app/src/App.vue @@ -5,6 +5,7 @@ import initCore from '@core/initCore' import { initConfigStore, useConfigStore } from '@core/stores/config' import { hexToRgb } from '@core/utils/colorConverter' import { useAuthStore } from '@/stores/useAuthStore' +import { useImpersonationStore } from '@/stores/useImpersonationStore' import { useNotificationStore } from '@/stores/useNotificationStore' const { global } = useTheme() @@ -14,8 +15,13 @@ initConfigStore() const configStore = useConfigStore() const authStore = useAuthStore() +const impersonationStore = useImpersonationStore() const notificationStore = useNotificationStore() +// Restore impersonation state and listen for cross-tab sync +impersonationStore.restoreFromStorage() +impersonationStore.listenForBroadcasts() + // Validate stored token on app startup — must complete before rendering protected content authStore.initialize() diff --git a/apps/app/src/components/platform/ImpersonateDialog.vue b/apps/app/src/components/platform/ImpersonateDialog.vue new file mode 100644 index 00000000..b9a26afd --- /dev/null +++ b/apps/app/src/components/platform/ImpersonateDialog.vue @@ -0,0 +1,183 @@ + + + diff --git a/apps/app/src/components/platform/ImpersonationBanner.vue b/apps/app/src/components/platform/ImpersonationBanner.vue index 05964a5c..dd0bf3f4 100644 --- a/apps/app/src/components/platform/ImpersonationBanner.vue +++ b/apps/app/src/components/platform/ImpersonationBanner.vue @@ -4,46 +4,115 @@ import { useImpersonationStore } from '@/stores/useImpersonationStore' const impersonationStore = useImpersonationStore() const isStopping = ref(false) +const remainingSeconds = ref(0) +let timerInterval: ReturnType | null = null + +const remainingFormatted = computed(() => { + const mins = Math.floor(remainingSeconds.value / 60) + const secs = remainingSeconds.value % 60 + + return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}` +}) + +function updateCountdown() { + if (!impersonationStore.expiresAt) { + remainingSeconds.value = 0 + + return + } + + const diff = Math.max(0, Math.floor((impersonationStore.expiresAt.getTime() - Date.now()) / 1000)) + remainingSeconds.value = diff + + if (diff <= 0) { + handleExpired() + } +} + +function handleExpired() { + if (timerInterval) { + clearInterval(timerInterval) + timerInterval = null + } + impersonationStore.clearState() + window.location.href = '/platform' +} async function handleStop() { isStopping.value = true - await impersonationStore.stopImpersonation() + await impersonationStore.stop() } + +watch(() => impersonationStore.isImpersonating, (active) => { + if (active) { + updateCountdown() + timerInterval = setInterval(updateCountdown, 1000) + } + else if (timerInterval) { + clearInterval(timerInterval) + timerInterval = null + } +}, { immediate: true }) + +onUnmounted(() => { + if (timerInterval) { + clearInterval(timerInterval) + } +}) diff --git a/apps/app/src/composables/api/useAdmin.ts b/apps/app/src/composables/api/useAdmin.ts index 2fa98d23..8c44e032 100644 --- a/apps/app/src/composables/api/useAdmin.ts +++ b/apps/app/src/composables/api/useAdmin.ts @@ -8,7 +8,6 @@ import type { AdminOrganisationMember, AdminUser, CreateOrganisationPayload, - ImpersonationResponse, InviteMemberPayload, PlatformStats, UpdateAdminOrganisationPayload, @@ -245,25 +244,4 @@ export function useAdminActivityLog(params: Ref { - const { data } = await apiClient.post>( - `/admin/impersonate/${userId}`, - ) - return data.data - }, - }) -} - -export function useStopImpersonation() { - return useMutation({ - mutationFn: async () => { - const { data } = await apiClient.post>( - '/admin/stop-impersonation', - ) - return data.data - }, - }) -} +// Impersonation API calls are now handled directly by useImpersonationStore. diff --git a/apps/app/src/lib/axios.ts b/apps/app/src/lib/axios.ts index 4ab70cee..e717df72 100644 --- a/apps/app/src/lib/axios.ts +++ b/apps/app/src/lib/axios.ts @@ -21,6 +21,21 @@ apiClient.interceptors.request.use( config.headers['X-Organisation-Id'] = orgStore.activeOrganisationId } + // Add impersonation header when active + // Lazy import to avoid circular dependency with store + const impersonationData = sessionStorage.getItem('crewli_impersonation') + if (impersonationData) { + try { + const parsed = JSON.parse(impersonationData) as { targetUserId?: string } + if (parsed.targetUserId) { + config.headers['X-Impersonate-User'] = parsed.targetUserId + } + } + catch { + // Invalid data — ignore + } + } + if (import.meta.env.DEV) { console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data) } @@ -46,6 +61,17 @@ apiClient.interceptors.response.use( const status = error.response?.status const notificationStore = useNotificationStore() + // Handle impersonation session expiry + if (status === 403 && error.response?.data?.impersonation_ended) { + import('@/stores/useImpersonationStore').then(({ useImpersonationStore }) => { + const impersonationStore = useImpersonationStore() + impersonationStore.clearState() + window.location.href = '/platform' + }) + + return Promise.reject(error) + } + if (status === 401) { // Lazy import to avoid circular dependency import('@/stores/useAuthStore').then(({ useAuthStore }) => { diff --git a/apps/app/src/pages/platform/users/[id].vue b/apps/app/src/pages/platform/users/[id].vue index 3022e84b..1241e445 100644 --- a/apps/app/src/pages/platform/users/[id].vue +++ b/apps/app/src/pages/platform/users/[id].vue @@ -2,11 +2,10 @@ import { useAdminUser, useUpdateAdminUser, - useStartImpersonation, } from '@/composables/api/useAdmin' import { useAdminResetMfa } from '@/composables/api/useMfa' -import { useImpersonationStore } from '@/stores/useImpersonationStore' -import type { AdminUser, UpdateAdminUserPayload } from '@/types/admin' +import ImpersonateDialog from '@/components/platform/ImpersonateDialog.vue' +import type { UpdateAdminUserPayload } from '@/types/admin' definePage({ meta: { @@ -16,7 +15,6 @@ definePage({ const route = useRoute() const router = useRouter() -const impersonationStore = useImpersonationStore() const userId = computed(() => String((route.params as { id: string }).id)) @@ -76,16 +74,6 @@ function submitEdit() { // Impersonation const isImpersonateDialogOpen = ref(false) -const { mutate: startImpersonation, isPending: isImpersonating } = useStartImpersonation() - -function confirmImpersonate() { - startImpersonation(userId.value, { - onSuccess: (result) => { - isImpersonateDialogOpen.value = false - impersonationStore.startImpersonation(result.token, result.user, result.admin_id) - }, - }) -} // MFA Reset const isMfaResetDialogOpen = ref(false) @@ -416,33 +404,10 @@ function getInitials(name: string): string { - - - - Je gaat inloggen als {{ user?.full_name }} - ({{ user?.email }}). Wil je doorgaan? - - - - - Annuleren - - - Doorgaan - - - - + :user="user ?? null" + /> import { useAdminUsers, - useStartImpersonation, useDeleteAdminUser, } from '@/composables/api/useAdmin' -import { useImpersonationStore } from '@/stores/useImpersonationStore' +import ImpersonateDialog from '@/components/platform/ImpersonateDialog.vue' import type { AdminUser } from '@/types/admin' definePage({ @@ -14,7 +13,6 @@ definePage({ }) const router = useRouter() -const impersonationStore = useImpersonationStore() const search = ref('') const searchDebounced = refDebounced(search, 400) @@ -57,24 +55,12 @@ const headers = [ // Impersonation const isImpersonateDialogOpen = ref(false) const userToImpersonate = ref(null) -const { mutate: startImpersonation, isPending: isImpersonating } = useStartImpersonation() function openImpersonateDialog(user: AdminUser) { userToImpersonate.value = user isImpersonateDialogOpen.value = true } -function confirmImpersonate() { - if (!userToImpersonate.value) return - - startImpersonation(userToImpersonate.value.id, { - onSuccess: (result) => { - isImpersonateDialogOpen.value = false - impersonationStore.startImpersonation(result.token, result.user, result.admin_id) - }, - }) -} - // Delete const isDeleteDialogOpen = ref(false) const userToDelete = ref(null) @@ -296,33 +282,10 @@ function onUpdateOptions(options: { page: number; itemsPerPage: number }) { - - - - Je gaat inloggen als {{ userToImpersonate?.full_name }} - ({{ userToImpersonate?.email }}). Wil je doorgaan? - - - - - Annuleren - - - Doorgaan - - - - + :user="userToImpersonate" + /> { - const stored = localStorage.getItem(IMPERSONATION_KEY) + const stored = sessionStorage.getItem(SESSION_STORAGE_KEY) const state = ref(stored ? JSON.parse(stored) : null) + let broadcastChannel: BroadcastChannel | null = null const isImpersonating = computed(() => !!state.value) const originalAdminId = computed(() => state.value?.adminId ?? null) const impersonatedUser = computed(() => state.value?.impersonatedUser ?? null) + const sessionId = computed(() => state.value?.sessionId ?? null) + const targetUserId = computed(() => state.value?.targetUserId ?? null) + const expiresAt = computed(() => state.value?.expiresAt ? new Date(state.value.expiresAt) : null) - function startImpersonation(token: string, user: AdminUser, adminId: string) { - // Store the current cookie token reference (we'll restore it on stop) - // Since the app uses httpOnly cookies, we store the admin ID to know we're impersonating - state.value = { - adminId, - originalToken: '', // httpOnly cookie — we can't read it, but we track the state - impersonatedUser: user, + function persistState(): void { + if (state.value) { + sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(state.value)) + } + else { + sessionStorage.removeItem(SESSION_STORAGE_KEY) } - localStorage.setItem(IMPERSONATION_KEY, JSON.stringify(state.value)) - - // The impersonation token from the API is a plain Sanctum token. - // Set it as a Bearer token header for subsequent requests. - apiClient.defaults.headers.common.Authorization = `Bearer ${token}` - - // Reload user state to reflect the impersonated user - const authStore = useAuthStore() - authStore.initialize() } - async function stopImpersonation() { + async function start( + userId: string, + payload: StartImpersonationPayload, + ): Promise { + const { data } = await apiClient.post<{ data: ImpersonationStartResponse }>( + `/admin/impersonate/${userId}`, + payload, + ) + + const result = data.data + const session = result.session + + state.value = { + sessionId: session.id, + adminId: session.admin_id, + targetUserId: session.target_user_id, + impersonatedUser: result.user, + expiresAt: session.expires_at, + } + + persistState() + broadcastChange('started') + + // Reload to apply impersonated context + window.location.href = '/' + + return result + } + + async function stop(): Promise { try { - await apiClient.post('/admin/stop-impersonation') + // Call stop WITHOUT the X-Impersonate-User header + // The interceptor won't add it because we clear state first + const currentState = state.value + state.value = null + persistState() + + if (currentState) { + await apiClient.post('/admin/stop-impersonation') + } } catch { - // Even if the API call fails, restore local state + // Even if API call fails, state is already cleared } - // Remove the Bearer token so httpOnly cookie takes over again - delete apiClient.defaults.headers.common.Authorization + broadcastChange('stopped') - state.value = null - localStorage.removeItem(IMPERSONATION_KEY) - - // Full reload to restore admin session from httpOnly cookie + // Full reload to restore admin session window.location.href = '/platform' } - function clearWithoutReload() { + function clearState(): void { state.value = null - localStorage.removeItem(IMPERSONATION_KEY) - delete apiClient.defaults.headers.common.Authorization + persistState() + } + + async function checkStatus(): Promise { + try { + const { data } = await apiClient.get<{ data: ImpersonationStatusResponse }>( + '/admin/impersonate/status', + ) + + if (!data.data.active) { + if (state.value) { + clearState() + window.location.href = '/platform' + } + } + else if (data.data.session) { + // Update expiry from server + if (state.value) { + state.value.expiresAt = data.data.session.expires_at + persistState() + } + } + } + catch { + // If status check fails, don't clear — might be a network issue + } + } + + function restoreFromStorage(): void { + const stored = sessionStorage.getItem(SESSION_STORAGE_KEY) + if (stored) { + try { + state.value = JSON.parse(stored) + } + catch { + sessionStorage.removeItem(SESSION_STORAGE_KEY) + state.value = null + } + } + } + + function listenForBroadcasts(): void { + if (broadcastChannel) return + + try { + broadcastChannel = new BroadcastChannel(BROADCAST_CHANNEL_NAME) + broadcastChannel.onmessage = (event: MessageEvent<{ type: string }>) => { + if (event.data.type === 'stopped') { + state.value = null + persistState() + window.location.href = '/platform' + } + else if (event.data.type === 'started') { + restoreFromStorage() + } + } + } + catch { + // BroadcastChannel not supported — no cross-tab sync + } + } + + function broadcastChange(type: string): void { + try { + broadcastChannel?.postMessage({ type }) + } + catch { + // Ignore broadcast errors + } } return { isImpersonating, originalAdminId, impersonatedUser, - startImpersonation, - stopImpersonation, - clearWithoutReload, + sessionId, + targetUserId, + expiresAt, + start, + stop, + clearState, + checkStatus, + restoreFromStorage, + listenForBroadcasts, } }) diff --git a/apps/app/src/types/admin.ts b/apps/app/src/types/admin.ts index 8b3d2d19..f8003ae1 100644 --- a/apps/app/src/types/admin.ts +++ b/apps/app/src/types/admin.ts @@ -67,10 +67,34 @@ export interface ActivityLogEntry { created_at: string } -export interface ImpersonationResponse { - token: string - user: AdminUser +export interface ImpersonationSession { + id: string admin_id: string + target_user_id: string + target_user?: AdminUser + reason: string + mfa_method: string + started_at: string + expires_at: string + ended_at: string | null + end_reason: string | null + actions_count: number +} + +export interface ImpersonationStartResponse { + session: ImpersonationSession + user: AdminUser +} + +export interface ImpersonationStatusResponse { + active: boolean + session?: ImpersonationSession +} + +export interface StartImpersonationPayload { + reason: string + mfa_code: string + mfa_method: 'totp' | 'email' | 'backup_code' } export interface AdminOrganisationMember { diff --git a/dev-docs/API.md b/dev-docs/API.md index 8a661da6..2ef4dd8b 100644 --- a/dev-docs/API.md +++ b/dev-docs/API.md @@ -926,17 +926,49 @@ Base path: `/api/v1/admin/` ### Admin Impersonation -- `POST /admin/impersonate/{user}` — start impersonating a user (requires `role:super_admin`) -- `POST /admin/stop-impersonation` — stop impersonation (requires `auth:sanctum` only, callable by impersonated user) +Header-based impersonation with MFA verification. See `AUTH_ARCHITECTURE.md` section 10 for full details. + +**Endpoints:** + +- `POST /admin/impersonate/{user}` — start impersonation (requires `role:super_admin` + MFA) +- `POST /admin/stop-impersonation` — stop impersonation (requires `auth:sanctum` only — admin calls without `X-Impersonate-User` header) +- `GET /admin/impersonate/status` — check active session (requires `role:super_admin`) +- `POST /admin/impersonate/send-mfa-code` — send email verification code to admin (requires `role:super_admin`) + +#### Start Request + +```json +{ + "reason": "Investigating user issue with shift assignments", + "mfa_code": "123456", + "mfa_method": "totp" +} +``` + +| Field | Type | Rules | +|-------|------|-------| +| `reason` | string | required, min:5, max:500 | +| `mfa_code` | string | required | +| `mfa_method` | string | required, in: `totp`, `email`, `backup_code` | #### Start Response ```json { "data": { - "token": "1|abc123...", - "user": { "...AdminUserResource..." }, - "admin_id": "01JXYZ..." + "session": { + "id": "01JXYZ...", + "admin_id": "01JXYZ...", + "target_user_id": "01JXYZ...", + "reason": "Investigating user issue", + "mfa_method": "totp", + "started_at": "2026-04-16T12:00:00+00:00", + "expires_at": "2026-04-16T13:00:00+00:00", + "ended_at": null, + "end_reason": null, + "actions_count": 0 + }, + "user": { "...AdminUserResource..." } } } ``` @@ -951,12 +983,33 @@ Base path: `/api/v1/admin/` } ``` +#### Status Response + +```json +{ + "data": { + "active": true, + "session": { "...ImpersonationSessionResource..." } + } +} +``` + +#### Impersonation Header + +During an active session, the frontend sends `X-Impersonate-User: {target_user_id}` on every request. The `HandleImpersonation` middleware validates the header against the cached session and swaps auth context. + #### Business Rules +- Admin must have MFA enabled (403 if not) - Cannot impersonate another super_admin (403) -- Impersonation token has name `impersonation-by-{admin_id}` -- Admin ID is cached for 4 hours at key `impersonation:{token_id}` -- Activity log records both start (`admin.impersonation.started`) and stop (`admin.impersonation.stopped`) +- Cannot nest impersonation sessions (403) +- Cannot impersonate a user already being impersonated (403) +- MFA code verified against admin's own MFA (TOTP, email, or backup code) +- Sessions expire after 60 minutes (sliding TTL, extended on each request) +- IP pinning: session terminated if admin's IP changes +- Sensitive routes blocked during impersonation (auth/*, me/*, admin/impersonate/*) +- All activity during session tagged with `impersonated_by` in properties +- Immutable audit trail in `impersonation_sessions` table ## Email Settings (org admin) diff --git a/dev-docs/AUTH_ARCHITECTURE.md b/dev-docs/AUTH_ARCHITECTURE.md index 36a4e488..a09077c7 100644 --- a/dev-docs/AUTH_ARCHITECTURE.md +++ b/dev-docs/AUTH_ARCHITECTURE.md @@ -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 | diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index 4b25c3af..88027bfc 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -330,6 +330,29 @@ scopeFestivals() // WHERE event_type IN ('festival', 'series') --- +### `impersonation_sessions` + +| Column | Type | Notes | +| ---------------- | ------------------ | ---------------------------------------- | +| `id` | ULID | PK | +| `admin_id` | ULID FK | → users | +| `target_user_id` | ULID FK | → users | +| `reason` | string | Admin-provided reason | +| `mfa_method` | string(20) | totp, email, or backup_code | +| `ip_address` | string(45) | Admin's IP at start | +| `user_agent` | text nullable | Admin's user agent | +| `started_at` | timestamp | | +| `ended_at` | timestamp nullable | NULL = still active | +| `expires_at` | timestamp | Sliding 60-min TTL | +| `end_reason` | string(50) nullable| manual, expired, ip_changed, admin_kill_all | +| `actions_count` | unsigned int | API requests made during session | + +**Relations:** `belongsTo` User (admin), `belongsTo` User (target) +**Indexes:** `(admin_id, ended_at)`, `(target_user_id, ended_at)`, `(started_at)` +**Soft delete:** no — immutable audit table + +--- + ## 3.5.2 Locations > Locations are event-scoped and reusable across sections within an event.