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,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.');
|
||||
}
|
||||
}
|
||||
|
||||
112
api/app/Http/Middleware/HandleImpersonation.php
Normal file
112
api/app/Http/Middleware/HandleImpersonation.php
Normal 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;
|
||||
}
|
||||
}
|
||||
25
api/app/Http/Requests/Admin/StartImpersonationRequest.php
Normal file
25
api/app/Http/Requests/Admin/StartImpersonationRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
63
api/app/Models/ImpersonationSession.php
Normal file
63
api/app/Models/ImpersonationSession.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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