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:
2026-04-16 02:42:53 +02:00
parent 47cb6b83d4
commit 4df668b5b8
25 changed files with 1813 additions and 269 deletions

View File

@@ -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.');
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\ImpersonationService;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class HandleImpersonation
{
/**
* Routes that are blocked during impersonation.
* These are prefix-matched against the request path (without api/v1 prefix).
*/
private const SENSITIVE_ROUTE_PREFIXES = [
'auth/password',
'auth/logout',
'auth/mfa',
'auth/trusted-devices',
'me/profile',
'me/change-password',
'me/change-email',
'admin/impersonate',
'verify-email-change',
];
public function __construct(
private readonly ImpersonationService $impersonationService,
) {}
public function handle(Request $request, Closure $next): Response
{
$targetUserId = $request->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;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
class StartImpersonationRequest extends FormRequest
{
public function authorize(): bool
{
return true; // Authorization handled in service
}
/** @return array<string, mixed> */
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'],
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Admin;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ImpersonationSessionResource extends JsonResource
{
/** @return array<string, mixed> */
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,
];
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Builder;
class ImpersonationSession extends Model
{
use HasFactory;
use HasUlids;
public $timestamps = false;
protected $fillable = [
'admin_id',
'target_user_id',
'reason',
'mfa_method',
'ip_address',
'user_agent',
'started_at',
'ended_at',
'expires_at',
'end_reason',
'actions_count',
];
protected function casts(): array
{
return [
'started_at' => '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());
}
}

View File

@@ -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);
}
});
}
}

View File

@@ -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);
Cache::forget("impersonation:{$currentToken->id}");
$currentToken->delete();
if (! $admin) {
abort(400, 'Could not resolve original admin session.');
->log('Stopped impersonating user (reason: ' . $endReason . ')');
}
return $admin;
/**
* Get active session for an admin user.
*/
public function getActiveSessionForAdmin(User $admin): ?ImpersonationSession
{
return ImpersonationSession::where('admin_id', $admin->id)
->active()
->first();
}
/**
* 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;
}
$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);
}
}

View File

@@ -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 {

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\ImpersonationSession;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<ImpersonationSession> */
final class ImpersonationSessionFactory extends Factory
{
protected $model = ImpersonationSession::class;
/** @return array<string, mixed> */
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',
]);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('impersonation_sessions', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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']);

View File

@@ -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();
}
}

View File

@@ -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']

View File

@@ -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()
</script>

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import { useImpersonationStore } from '@/stores/useImpersonationStore'
import type { AdminUser, StartImpersonationPayload } from '@/types/admin'
import { apiClient } from '@/lib/axios'
const props = defineProps<{
modelValue: boolean
user: AdminUser | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const impersonationStore = useImpersonationStore()
const isOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
})
const reason = ref('')
const mfaCode = ref('')
const mfaMethod = ref<StartImpersonationPayload['mfa_method']>('totp')
const errorMessage = ref('')
const isSubmitting = ref(false)
const isSendingEmailCode = ref(false)
const emailCodeSent = ref(false)
const mfaMethodOptions = [
{ title: 'Authenticator app', value: 'totp' as const },
{ title: 'E-mailcode', value: 'email' as const },
{ title: 'Backup code', value: 'backup_code' as const },
]
const isFormValid = computed(() =>
reason.value.length >= 5 && mfaCode.value.length > 0,
)
function resetForm() {
reason.value = ''
mfaCode.value = ''
mfaMethod.value = 'totp'
errorMessage.value = ''
emailCodeSent.value = false
}
watch(isOpen, (open) => {
if (!open) {
resetForm()
}
})
async function sendEmailCode() {
isSendingEmailCode.value = true
errorMessage.value = ''
try {
await apiClient.post('/admin/impersonate/send-mfa-code')
emailCodeSent.value = true
}
catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
errorMessage.value = error.response?.data?.message ?? 'Kon e-mailcode niet versturen.'
}
finally {
isSendingEmailCode.value = false
}
}
async function submit() {
if (!props.user || !isFormValid.value) return
isSubmitting.value = true
errorMessage.value = ''
try {
await impersonationStore.start(props.user.id, {
reason: reason.value,
mfa_code: mfaCode.value,
mfa_method: mfaMethod.value,
})
// start() triggers page reload — no further action needed
}
catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
errorMessage.value = error.response?.data?.message ?? 'Impersonation mislukt.'
isSubmitting.value = false
}
}
</script>
<template>
<VDialog
v-model="isOpen"
max-width="500"
>
<VCard title="Inloggen als gebruiker">
<VCardText>
<!-- Target user info -->
<VAlert
type="info"
variant="tonal"
class="mb-4"
density="comfortable"
>
Je gaat het platform bekijken als
<strong>{{ user?.full_name }}</strong>
({{ user?.email }}).
Verificatie met tweestapsverificatie is vereist.
</VAlert>
<!-- Error -->
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ errorMessage }}
</VAlert>
<!-- Reason -->
<AppTextField
v-model="reason"
label="Reden"
placeholder="Waarom log je in als deze gebruiker?"
:rules="[(v: string) => v.length >= 5 || 'Minimaal 5 tekens']"
class="mb-4"
/>
<!-- MFA method -->
<AppSelect
v-model="mfaMethod"
:items="mfaMethodOptions"
label="Verificatiemethode"
class="mb-4"
/>
<!-- Send email code button -->
<VBtn
v-if="mfaMethod === 'email'"
variant="tonal"
size="small"
:loading="isSendingEmailCode"
:disabled="emailCodeSent"
class="mb-4"
@click="sendEmailCode"
>
{{ emailCodeSent ? 'Code verstuurd' : 'Verstuur e-mailcode' }}
</VBtn>
<!-- MFA code -->
<AppTextField
v-model="mfaCode"
:label="mfaMethod === 'backup_code' ? 'Backup code' : 'Verificatiecode'"
:placeholder="mfaMethod === 'backup_code' ? 'XXXX-XXXX' : '000000'"
autocomplete="one-time-code"
@keyup.enter="submit"
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="isOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="warning"
:loading="isSubmitting"
:disabled="!isFormValid"
@click="submit"
>
Inloggen als gebruiker
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -4,46 +4,115 @@ import { useImpersonationStore } from '@/stores/useImpersonationStore'
const impersonationStore = useImpersonationStore()
const isStopping = ref(false)
const remainingSeconds = ref(0)
let timerInterval: ReturnType<typeof setInterval> | 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)
}
})
</script>
<template>
<VBanner
<VSystemBar
v-if="impersonationStore.isImpersonating"
color="warning"
sticky
class="impersonation-banner"
>
<template #prepend>
<VIcon icon="tabler-user-exclamation" />
</template>
<VBannerText>
<VIcon
icon="tabler-user-exclamation"
class="me-2"
/>
<span>
Je bekijkt het platform als
<strong>{{ impersonationStore.impersonatedUser?.full_name }}</strong>
({{ impersonationStore.impersonatedUser?.email }})
</VBannerText>
</span>
<VSpacer />
<VChip
size="small"
variant="tonal"
color="warning"
class="me-3"
>
<VIcon
icon="tabler-clock"
size="14"
class="me-1"
/>
{{ remainingFormatted }}
</VChip>
<template #actions>
<VBtn
variant="tonal"
color="warning"
size="small"
:loading="isStopping"
prepend-icon="tabler-arrow-back"
@click="handleStop"
>
Terug naar admin
</VBtn>
</template>
</VBanner>
</VSystemBar>
</template>
<style scoped>
/* VSystemBar uses a fixed height by default; override to accommodate the button */
.impersonation-banner {
z-index: 1050;
z-index: 9999;
block-size: auto;
min-block-size: 36px;
padding-block: 4px;
padding-inline: 16px;
}
</style>

View File

@@ -8,7 +8,6 @@ import type {
AdminOrganisationMember,
AdminUser,
CreateOrganisationPayload,
ImpersonationResponse,
InviteMemberPayload,
PlatformStats,
UpdateAdminOrganisationPayload,
@@ -245,25 +244,4 @@ export function useAdminActivityLog(params: Ref<Record<string, string | number |
}
// ─── Impersonation ──────────────────────────────────────────
export function useStartImpersonation() {
return useMutation({
mutationFn: async (userId: string) => {
const { data } = await apiClient.post<ApiResponse<ImpersonationResponse>>(
`/admin/impersonate/${userId}`,
)
return data.data
},
})
}
export function useStopImpersonation() {
return useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<ApiResponse<{ user: AdminUser }>>(
'/admin/stop-impersonation',
)
return data.data
},
})
}
// Impersonation API calls are now handled directly by useImpersonationStore.

View File

@@ -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 }) => {

View File

@@ -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 {
</VDialog>
<!-- Impersonate Dialog -->
<VDialog
<ImpersonateDialog
v-model="isImpersonateDialogOpen"
max-width="400"
>
<VCard title="Inloggen als gebruiker">
<VCardText>
Je gaat inloggen als <strong>{{ user?.full_name }}</strong>
({{ user?.email }}). Wil je doorgaan?
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isImpersonateDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="warning"
:loading="isImpersonating"
@click="confirmImpersonate"
>
Doorgaan
</VBtn>
</VCardActions>
</VCard>
</VDialog>
:user="user ?? null"
/>
<!-- MFA Reset Dialog -->
<VDialog

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
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<AdminUser | null>(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<AdminUser | null>(null)
@@ -296,33 +282,10 @@ function onUpdateOptions(options: { page: number; itemsPerPage: number }) {
</VCard>
<!-- Impersonate Dialog -->
<VDialog
<ImpersonateDialog
v-model="isImpersonateDialogOpen"
max-width="400"
>
<VCard title="Inloggen als gebruiker">
<VCardText>
Je gaat inloggen als <strong>{{ userToImpersonate?.full_name }}</strong>
({{ userToImpersonate?.email }}). Wil je doorgaan?
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isImpersonateDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="warning"
:loading="isImpersonating"
@click="confirmImpersonate"
>
Doorgaan
</VBtn>
</VCardActions>
</VCard>
</VDialog>
:user="userToImpersonate"
/>
<!-- Delete Dialog -->
<VDialog

View File

@@ -1,74 +1,176 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { apiClient } from '@/lib/axios'
import { useAuthStore } from '@/stores/useAuthStore'
import type { AdminUser } from '@/types/admin'
import type { AdminUser, ImpersonationSession, ImpersonationStartResponse, ImpersonationStatusResponse, StartImpersonationPayload } from '@/types/admin'
const IMPERSONATION_KEY = 'crewli_impersonation'
const SESSION_STORAGE_KEY = 'crewli_impersonation'
const BROADCAST_CHANNEL_NAME = 'crewli_impersonation_sync'
interface ImpersonationState {
sessionId: string
adminId: string
originalToken: string
targetUserId: string
impersonatedUser: AdminUser
expiresAt: string
}
export const useImpersonationStore = defineStore('impersonation', () => {
const stored = localStorage.getItem(IMPERSONATION_KEY)
const stored = sessionStorage.getItem(SESSION_STORAGE_KEY)
const state = ref<ImpersonationState | null>(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 persistState(): void {
if (state.value) {
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(state.value))
}
else {
sessionStorage.removeItem(SESSION_STORAGE_KEY)
}
}
async function start(
userId: string,
payload: StartImpersonationPayload,
): Promise<ImpersonationStartResponse> {
const { data } = await apiClient.post<{ data: ImpersonationStartResponse }>(
`/admin/impersonate/${userId}`,
payload,
)
const result = data.data
const session = result.session
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,
}
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()
sessionId: session.id,
adminId: session.admin_id,
targetUserId: session.target_user_id,
impersonatedUser: result.user,
expiresAt: session.expires_at,
}
async function stopImpersonation() {
persistState()
broadcastChange('started')
// Reload to apply impersonated context
window.location.href = '/'
return result
}
async function stop(): Promise<void> {
try {
// 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<void> {
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,
}
})

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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 |

View File

@@ -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.