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>
284 lines
8.4 KiB
PHP
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);
|
|
}
|
|
}
|