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

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