Files
crewli/api/app/Services/ImpersonationService.php
bert.hausmans 4df668b5b8 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>
2026-04-16 02:42:53 +02:00

284 lines
8.4 KiB
PHP

<?php
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 an impersonation session.
* Requires valid MFA code from the admin.
*/
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.');
}
// Cannot nest: admin already impersonating someone
if ($this->getActiveSessionForAdmin($admin)) {
abort(403, 'You already have an active impersonation session.');
}
// 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 $session;
}
/**
* Stop an impersonation session.
*/
public function stop(ImpersonationSession $session, string $endReason = 'manual'): void
{
$session->update([
'ended_at' => now(),
'end_reason' => $endReason,
]);
$this->clearCacheSession($session->admin_id, $session->target_user_id);
activity('admin')
->causedBy(User::find($session->admin_id))
->performedOn(User::find($session->target_user_id))
->event('admin.impersonation.stopped')
->withProperties([
'session_id' => $session->id,
'end_reason' => $endReason,
'actions_count' => $session->actions_count,
])
->log('Stopped impersonating user (reason: ' . $endReason . ')');
}
/**
* 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);
}
}