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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user