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);
|
||||
|
||||
Cache::forget("impersonation:{$currentToken->id}");
|
||||
$currentToken->delete();
|
||||
|
||||
if (! $admin) {
|
||||
abort(400, 'Could not resolve original admin session.');
|
||||
->log('Stopped impersonating user (reason: ' . $endReason . ')');
|
||||
}
|
||||
|
||||
return $admin;
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
49
api/database/factories/ImpersonationSessionFactory.php
Normal file
49
api/database/factories/ImpersonationSessionFactory.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
1
apps/app/components.d.ts
vendored
1
apps/app/components.d.ts
vendored
@@ -67,6 +67,7 @@ declare module 'vue' {
|
||||
EventMetricCards: typeof import('./src/components/events/EventMetricCards.vue')['default']
|
||||
EventTabsNav: typeof import('./src/components/events/EventTabsNav.vue')['default']
|
||||
I18n: typeof import('./src/@core/components/I18n.vue')['default']
|
||||
ImpersonateDialog: typeof import('./src/components/platform/ImpersonateDialog.vue')['default']
|
||||
ImpersonationBanner: typeof import('./src/components/platform/ImpersonationBanner.vue')['default']
|
||||
ImportFromEventDialog: typeof import('./src/components/event/ImportFromEventDialog.vue')['default']
|
||||
InfoTooltip: typeof import('./src/components/common/InfoTooltip.vue')['default']
|
||||
|
||||
@@ -5,6 +5,7 @@ import initCore from '@core/initCore'
|
||||
import { initConfigStore, useConfigStore } from '@core/stores/config'
|
||||
import { hexToRgb } from '@core/utils/colorConverter'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
||||
import { useNotificationStore } from '@/stores/useNotificationStore'
|
||||
|
||||
const { global } = useTheme()
|
||||
@@ -14,8 +15,13 @@ initConfigStore()
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const authStore = useAuthStore()
|
||||
const impersonationStore = useImpersonationStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
// Restore impersonation state and listen for cross-tab sync
|
||||
impersonationStore.restoreFromStorage()
|
||||
impersonationStore.listenForBroadcasts()
|
||||
|
||||
// Validate stored token on app startup — must complete before rendering protected content
|
||||
authStore.initialize()
|
||||
</script>
|
||||
|
||||
183
apps/app/src/components/platform/ImpersonateDialog.vue
Normal file
183
apps/app/src/components/platform/ImpersonateDialog.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
||||
import type { AdminUser, StartImpersonationPayload } from '@/types/admin'
|
||||
import { apiClient } from '@/lib/axios'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
user: AdminUser | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const impersonationStore = useImpersonationStore()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const reason = ref('')
|
||||
const mfaCode = ref('')
|
||||
const mfaMethod = ref<StartImpersonationPayload['mfa_method']>('totp')
|
||||
const errorMessage = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
const isSendingEmailCode = ref(false)
|
||||
const emailCodeSent = ref(false)
|
||||
|
||||
const mfaMethodOptions = [
|
||||
{ title: 'Authenticator app', value: 'totp' as const },
|
||||
{ title: 'E-mailcode', value: 'email' as const },
|
||||
{ title: 'Backup code', value: 'backup_code' as const },
|
||||
]
|
||||
|
||||
const isFormValid = computed(() =>
|
||||
reason.value.length >= 5 && mfaCode.value.length > 0,
|
||||
)
|
||||
|
||||
function resetForm() {
|
||||
reason.value = ''
|
||||
mfaCode.value = ''
|
||||
mfaMethod.value = 'totp'
|
||||
errorMessage.value = ''
|
||||
emailCodeSent.value = false
|
||||
}
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (!open) {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
|
||||
async function sendEmailCode() {
|
||||
isSendingEmailCode.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await apiClient.post('/admin/impersonate/send-mfa-code')
|
||||
emailCodeSent.value = true
|
||||
}
|
||||
catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
errorMessage.value = error.response?.data?.message ?? 'Kon e-mailcode niet versturen.'
|
||||
}
|
||||
finally {
|
||||
isSendingEmailCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!props.user || !isFormValid.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await impersonationStore.start(props.user.id, {
|
||||
reason: reason.value,
|
||||
mfa_code: mfaCode.value,
|
||||
mfa_method: mfaMethod.value,
|
||||
})
|
||||
// start() triggers page reload — no further action needed
|
||||
}
|
||||
catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
errorMessage.value = error.response?.data?.message ?? 'Impersonation mislukt.'
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
v-model="isOpen"
|
||||
max-width="500"
|
||||
>
|
||||
<VCard title="Inloggen als gebruiker">
|
||||
<VCardText>
|
||||
<!-- Target user info -->
|
||||
<VAlert
|
||||
type="info"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
density="comfortable"
|
||||
>
|
||||
Je gaat het platform bekijken als
|
||||
<strong>{{ user?.full_name }}</strong>
|
||||
({{ user?.email }}).
|
||||
Verificatie met tweestapsverificatie is vereist.
|
||||
</VAlert>
|
||||
|
||||
<!-- Error -->
|
||||
<VAlert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
density="comfortable"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</VAlert>
|
||||
|
||||
<!-- Reason -->
|
||||
<AppTextField
|
||||
v-model="reason"
|
||||
label="Reden"
|
||||
placeholder="Waarom log je in als deze gebruiker?"
|
||||
:rules="[(v: string) => v.length >= 5 || 'Minimaal 5 tekens']"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<!-- MFA method -->
|
||||
<AppSelect
|
||||
v-model="mfaMethod"
|
||||
:items="mfaMethodOptions"
|
||||
label="Verificatiemethode"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<!-- Send email code button -->
|
||||
<VBtn
|
||||
v-if="mfaMethod === 'email'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
:loading="isSendingEmailCode"
|
||||
:disabled="emailCodeSent"
|
||||
class="mb-4"
|
||||
@click="sendEmailCode"
|
||||
>
|
||||
{{ emailCodeSent ? 'Code verstuurd' : 'Verstuur e-mailcode' }}
|
||||
</VBtn>
|
||||
|
||||
<!-- MFA code -->
|
||||
<AppTextField
|
||||
v-model="mfaCode"
|
||||
:label="mfaMethod === 'backup_code' ? 'Backup code' : 'Verificatiecode'"
|
||||
:placeholder="mfaMethod === 'backup_code' ? 'XXXX-XXXX' : '000000'"
|
||||
autocomplete="one-time-code"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="warning"
|
||||
:loading="isSubmitting"
|
||||
:disabled="!isFormValid"
|
||||
@click="submit"
|
||||
>
|
||||
Inloggen als gebruiker
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -4,46 +4,115 @@ import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
||||
const impersonationStore = useImpersonationStore()
|
||||
|
||||
const isStopping = ref(false)
|
||||
const remainingSeconds = ref(0)
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const remainingFormatted = computed(() => {
|
||||
const mins = Math.floor(remainingSeconds.value / 60)
|
||||
const secs = remainingSeconds.value % 60
|
||||
|
||||
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
|
||||
})
|
||||
|
||||
function updateCountdown() {
|
||||
if (!impersonationStore.expiresAt) {
|
||||
remainingSeconds.value = 0
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const diff = Math.max(0, Math.floor((impersonationStore.expiresAt.getTime() - Date.now()) / 1000))
|
||||
remainingSeconds.value = diff
|
||||
|
||||
if (diff <= 0) {
|
||||
handleExpired()
|
||||
}
|
||||
}
|
||||
|
||||
function handleExpired() {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval)
|
||||
timerInterval = null
|
||||
}
|
||||
impersonationStore.clearState()
|
||||
window.location.href = '/platform'
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
isStopping.value = true
|
||||
await impersonationStore.stopImpersonation()
|
||||
await impersonationStore.stop()
|
||||
}
|
||||
|
||||
watch(() => impersonationStore.isImpersonating, (active) => {
|
||||
if (active) {
|
||||
updateCountdown()
|
||||
timerInterval = setInterval(updateCountdown, 1000)
|
||||
}
|
||||
else if (timerInterval) {
|
||||
clearInterval(timerInterval)
|
||||
timerInterval = null
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VBanner
|
||||
<VSystemBar
|
||||
v-if="impersonationStore.isImpersonating"
|
||||
color="warning"
|
||||
sticky
|
||||
class="impersonation-banner"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="tabler-user-exclamation" />
|
||||
</template>
|
||||
|
||||
<VBannerText>
|
||||
<VIcon
|
||||
icon="tabler-user-exclamation"
|
||||
class="me-2"
|
||||
/>
|
||||
<span>
|
||||
Je bekijkt het platform als
|
||||
<strong>{{ impersonationStore.impersonatedUser?.full_name }}</strong>
|
||||
({{ impersonationStore.impersonatedUser?.email }})
|
||||
</VBannerText>
|
||||
</span>
|
||||
|
||||
<VSpacer />
|
||||
|
||||
<VChip
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="warning"
|
||||
class="me-3"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-clock"
|
||||
size="14"
|
||||
class="me-1"
|
||||
/>
|
||||
{{ remainingFormatted }}
|
||||
</VChip>
|
||||
|
||||
<template #actions>
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
color="warning"
|
||||
size="small"
|
||||
:loading="isStopping"
|
||||
prepend-icon="tabler-arrow-back"
|
||||
@click="handleStop"
|
||||
>
|
||||
Terug naar admin
|
||||
</VBtn>
|
||||
</template>
|
||||
</VBanner>
|
||||
</VSystemBar>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* VSystemBar uses a fixed height by default; override to accommodate the button */
|
||||
.impersonation-banner {
|
||||
z-index: 1050;
|
||||
z-index: 9999;
|
||||
block-size: auto;
|
||||
min-block-size: 36px;
|
||||
padding-block: 4px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
AdminOrganisationMember,
|
||||
AdminUser,
|
||||
CreateOrganisationPayload,
|
||||
ImpersonationResponse,
|
||||
InviteMemberPayload,
|
||||
PlatformStats,
|
||||
UpdateAdminOrganisationPayload,
|
||||
@@ -245,25 +244,4 @@ export function useAdminActivityLog(params: Ref<Record<string, string | number |
|
||||
}
|
||||
|
||||
// ─── Impersonation ──────────────────────────────────────────
|
||||
|
||||
export function useStartImpersonation() {
|
||||
return useMutation({
|
||||
mutationFn: async (userId: string) => {
|
||||
const { data } = await apiClient.post<ApiResponse<ImpersonationResponse>>(
|
||||
`/admin/impersonate/${userId}`,
|
||||
)
|
||||
return data.data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useStopImpersonation() {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await apiClient.post<ApiResponse<{ user: AdminUser }>>(
|
||||
'/admin/stop-impersonation',
|
||||
)
|
||||
return data.data
|
||||
},
|
||||
})
|
||||
}
|
||||
// Impersonation API calls are now handled directly by useImpersonationStore.
|
||||
|
||||
@@ -21,6 +21,21 @@ apiClient.interceptors.request.use(
|
||||
config.headers['X-Organisation-Id'] = orgStore.activeOrganisationId
|
||||
}
|
||||
|
||||
// Add impersonation header when active
|
||||
// Lazy import to avoid circular dependency with store
|
||||
const impersonationData = sessionStorage.getItem('crewli_impersonation')
|
||||
if (impersonationData) {
|
||||
try {
|
||||
const parsed = JSON.parse(impersonationData) as { targetUserId?: string }
|
||||
if (parsed.targetUserId) {
|
||||
config.headers['X-Impersonate-User'] = parsed.targetUserId
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Invalid data — ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data)
|
||||
}
|
||||
@@ -46,6 +61,17 @@ apiClient.interceptors.response.use(
|
||||
const status = error.response?.status
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
// Handle impersonation session expiry
|
||||
if (status === 403 && error.response?.data?.impersonation_ended) {
|
||||
import('@/stores/useImpersonationStore').then(({ useImpersonationStore }) => {
|
||||
const impersonationStore = useImpersonationStore()
|
||||
impersonationStore.clearState()
|
||||
window.location.href = '/platform'
|
||||
})
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (status === 401) {
|
||||
// Lazy import to avoid circular dependency
|
||||
import('@/stores/useAuthStore').then(({ useAuthStore }) => {
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
import {
|
||||
useAdminUser,
|
||||
useUpdateAdminUser,
|
||||
useStartImpersonation,
|
||||
} from '@/composables/api/useAdmin'
|
||||
import { useAdminResetMfa } from '@/composables/api/useMfa'
|
||||
import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
||||
import type { AdminUser, UpdateAdminUserPayload } from '@/types/admin'
|
||||
import ImpersonateDialog from '@/components/platform/ImpersonateDialog.vue'
|
||||
import type { UpdateAdminUserPayload } from '@/types/admin'
|
||||
|
||||
definePage({
|
||||
meta: {
|
||||
@@ -16,7 +15,6 @@ definePage({
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const impersonationStore = useImpersonationStore()
|
||||
|
||||
const userId = computed(() => String((route.params as { id: string }).id))
|
||||
|
||||
@@ -76,16 +74,6 @@ function submitEdit() {
|
||||
|
||||
// Impersonation
|
||||
const isImpersonateDialogOpen = ref(false)
|
||||
const { mutate: startImpersonation, isPending: isImpersonating } = useStartImpersonation()
|
||||
|
||||
function confirmImpersonate() {
|
||||
startImpersonation(userId.value, {
|
||||
onSuccess: (result) => {
|
||||
isImpersonateDialogOpen.value = false
|
||||
impersonationStore.startImpersonation(result.token, result.user, result.admin_id)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// MFA Reset
|
||||
const isMfaResetDialogOpen = ref(false)
|
||||
@@ -416,33 +404,10 @@ function getInitials(name: string): string {
|
||||
</VDialog>
|
||||
|
||||
<!-- Impersonate Dialog -->
|
||||
<VDialog
|
||||
<ImpersonateDialog
|
||||
v-model="isImpersonateDialogOpen"
|
||||
max-width="400"
|
||||
>
|
||||
<VCard title="Inloggen als gebruiker">
|
||||
<VCardText>
|
||||
Je gaat inloggen als <strong>{{ user?.full_name }}</strong>
|
||||
({{ user?.email }}). Wil je doorgaan?
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="isImpersonateDialogOpen = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="warning"
|
||||
:loading="isImpersonating"
|
||||
@click="confirmImpersonate"
|
||||
>
|
||||
Doorgaan
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
:user="user ?? null"
|
||||
/>
|
||||
|
||||
<!-- MFA Reset Dialog -->
|
||||
<VDialog
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
useAdminUsers,
|
||||
useStartImpersonation,
|
||||
useDeleteAdminUser,
|
||||
} from '@/composables/api/useAdmin'
|
||||
import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
||||
import ImpersonateDialog from '@/components/platform/ImpersonateDialog.vue'
|
||||
import type { AdminUser } from '@/types/admin'
|
||||
|
||||
definePage({
|
||||
@@ -14,7 +13,6 @@ definePage({
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const impersonationStore = useImpersonationStore()
|
||||
|
||||
const search = ref('')
|
||||
const searchDebounced = refDebounced(search, 400)
|
||||
@@ -57,24 +55,12 @@ const headers = [
|
||||
// Impersonation
|
||||
const isImpersonateDialogOpen = ref(false)
|
||||
const userToImpersonate = ref<AdminUser | null>(null)
|
||||
const { mutate: startImpersonation, isPending: isImpersonating } = useStartImpersonation()
|
||||
|
||||
function openImpersonateDialog(user: AdminUser) {
|
||||
userToImpersonate.value = user
|
||||
isImpersonateDialogOpen.value = true
|
||||
}
|
||||
|
||||
function confirmImpersonate() {
|
||||
if (!userToImpersonate.value) return
|
||||
|
||||
startImpersonation(userToImpersonate.value.id, {
|
||||
onSuccess: (result) => {
|
||||
isImpersonateDialogOpen.value = false
|
||||
impersonationStore.startImpersonation(result.token, result.user, result.admin_id)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Delete
|
||||
const isDeleteDialogOpen = ref(false)
|
||||
const userToDelete = ref<AdminUser | null>(null)
|
||||
@@ -296,33 +282,10 @@ function onUpdateOptions(options: { page: number; itemsPerPage: number }) {
|
||||
</VCard>
|
||||
|
||||
<!-- Impersonate Dialog -->
|
||||
<VDialog
|
||||
<ImpersonateDialog
|
||||
v-model="isImpersonateDialogOpen"
|
||||
max-width="400"
|
||||
>
|
||||
<VCard title="Inloggen als gebruiker">
|
||||
<VCardText>
|
||||
Je gaat inloggen als <strong>{{ userToImpersonate?.full_name }}</strong>
|
||||
({{ userToImpersonate?.email }}). Wil je doorgaan?
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="isImpersonateDialogOpen = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="warning"
|
||||
:loading="isImpersonating"
|
||||
@click="confirmImpersonate"
|
||||
>
|
||||
Doorgaan
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
:user="userToImpersonate"
|
||||
/>
|
||||
|
||||
<!-- Delete Dialog -->
|
||||
<VDialog
|
||||
|
||||
@@ -1,74 +1,176 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { apiClient } from '@/lib/axios'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import type { AdminUser } from '@/types/admin'
|
||||
import type { AdminUser, ImpersonationSession, ImpersonationStartResponse, ImpersonationStatusResponse, StartImpersonationPayload } from '@/types/admin'
|
||||
|
||||
const IMPERSONATION_KEY = 'crewli_impersonation'
|
||||
const SESSION_STORAGE_KEY = 'crewli_impersonation'
|
||||
const BROADCAST_CHANNEL_NAME = 'crewli_impersonation_sync'
|
||||
|
||||
interface ImpersonationState {
|
||||
sessionId: string
|
||||
adminId: string
|
||||
originalToken: string
|
||||
targetUserId: string
|
||||
impersonatedUser: AdminUser
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export const useImpersonationStore = defineStore('impersonation', () => {
|
||||
const stored = localStorage.getItem(IMPERSONATION_KEY)
|
||||
const stored = sessionStorage.getItem(SESSION_STORAGE_KEY)
|
||||
const state = ref<ImpersonationState | null>(stored ? JSON.parse(stored) : null)
|
||||
let broadcastChannel: BroadcastChannel | null = null
|
||||
|
||||
const isImpersonating = computed(() => !!state.value)
|
||||
const originalAdminId = computed(() => state.value?.adminId ?? null)
|
||||
const impersonatedUser = computed(() => state.value?.impersonatedUser ?? null)
|
||||
const sessionId = computed(() => state.value?.sessionId ?? null)
|
||||
const targetUserId = computed(() => state.value?.targetUserId ?? null)
|
||||
const expiresAt = computed(() => state.value?.expiresAt ? new Date(state.value.expiresAt) : null)
|
||||
|
||||
function persistState(): void {
|
||||
if (state.value) {
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(state.value))
|
||||
}
|
||||
else {
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
async function start(
|
||||
userId: string,
|
||||
payload: StartImpersonationPayload,
|
||||
): Promise<ImpersonationStartResponse> {
|
||||
const { data } = await apiClient.post<{ data: ImpersonationStartResponse }>(
|
||||
`/admin/impersonate/${userId}`,
|
||||
payload,
|
||||
)
|
||||
|
||||
const result = data.data
|
||||
const session = result.session
|
||||
|
||||
function startImpersonation(token: string, user: AdminUser, adminId: string) {
|
||||
// Store the current cookie token reference (we'll restore it on stop)
|
||||
// Since the app uses httpOnly cookies, we store the admin ID to know we're impersonating
|
||||
state.value = {
|
||||
adminId,
|
||||
originalToken: '', // httpOnly cookie — we can't read it, but we track the state
|
||||
impersonatedUser: user,
|
||||
}
|
||||
localStorage.setItem(IMPERSONATION_KEY, JSON.stringify(state.value))
|
||||
|
||||
// The impersonation token from the API is a plain Sanctum token.
|
||||
// Set it as a Bearer token header for subsequent requests.
|
||||
apiClient.defaults.headers.common.Authorization = `Bearer ${token}`
|
||||
|
||||
// Reload user state to reflect the impersonated user
|
||||
const authStore = useAuthStore()
|
||||
authStore.initialize()
|
||||
sessionId: session.id,
|
||||
adminId: session.admin_id,
|
||||
targetUserId: session.target_user_id,
|
||||
impersonatedUser: result.user,
|
||||
expiresAt: session.expires_at,
|
||||
}
|
||||
|
||||
async function stopImpersonation() {
|
||||
persistState()
|
||||
broadcastChange('started')
|
||||
|
||||
// Reload to apply impersonated context
|
||||
window.location.href = '/'
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function stop(): Promise<void> {
|
||||
try {
|
||||
// Call stop WITHOUT the X-Impersonate-User header
|
||||
// The interceptor won't add it because we clear state first
|
||||
const currentState = state.value
|
||||
state.value = null
|
||||
persistState()
|
||||
|
||||
if (currentState) {
|
||||
await apiClient.post('/admin/stop-impersonation')
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Even if the API call fails, restore local state
|
||||
// Even if API call fails, state is already cleared
|
||||
}
|
||||
|
||||
// Remove the Bearer token so httpOnly cookie takes over again
|
||||
delete apiClient.defaults.headers.common.Authorization
|
||||
broadcastChange('stopped')
|
||||
|
||||
state.value = null
|
||||
localStorage.removeItem(IMPERSONATION_KEY)
|
||||
|
||||
// Full reload to restore admin session from httpOnly cookie
|
||||
// Full reload to restore admin session
|
||||
window.location.href = '/platform'
|
||||
}
|
||||
|
||||
function clearWithoutReload() {
|
||||
function clearState(): void {
|
||||
state.value = null
|
||||
localStorage.removeItem(IMPERSONATION_KEY)
|
||||
delete apiClient.defaults.headers.common.Authorization
|
||||
persistState()
|
||||
}
|
||||
|
||||
async function checkStatus(): Promise<void> {
|
||||
try {
|
||||
const { data } = await apiClient.get<{ data: ImpersonationStatusResponse }>(
|
||||
'/admin/impersonate/status',
|
||||
)
|
||||
|
||||
if (!data.data.active) {
|
||||
if (state.value) {
|
||||
clearState()
|
||||
window.location.href = '/platform'
|
||||
}
|
||||
}
|
||||
else if (data.data.session) {
|
||||
// Update expiry from server
|
||||
if (state.value) {
|
||||
state.value.expiresAt = data.data.session.expires_at
|
||||
persistState()
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// If status check fails, don't clear — might be a network issue
|
||||
}
|
||||
}
|
||||
|
||||
function restoreFromStorage(): void {
|
||||
const stored = sessionStorage.getItem(SESSION_STORAGE_KEY)
|
||||
if (stored) {
|
||||
try {
|
||||
state.value = JSON.parse(stored)
|
||||
}
|
||||
catch {
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY)
|
||||
state.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function listenForBroadcasts(): void {
|
||||
if (broadcastChannel) return
|
||||
|
||||
try {
|
||||
broadcastChannel = new BroadcastChannel(BROADCAST_CHANNEL_NAME)
|
||||
broadcastChannel.onmessage = (event: MessageEvent<{ type: string }>) => {
|
||||
if (event.data.type === 'stopped') {
|
||||
state.value = null
|
||||
persistState()
|
||||
window.location.href = '/platform'
|
||||
}
|
||||
else if (event.data.type === 'started') {
|
||||
restoreFromStorage()
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// BroadcastChannel not supported — no cross-tab sync
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastChange(type: string): void {
|
||||
try {
|
||||
broadcastChannel?.postMessage({ type })
|
||||
}
|
||||
catch {
|
||||
// Ignore broadcast errors
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isImpersonating,
|
||||
originalAdminId,
|
||||
impersonatedUser,
|
||||
startImpersonation,
|
||||
stopImpersonation,
|
||||
clearWithoutReload,
|
||||
sessionId,
|
||||
targetUserId,
|
||||
expiresAt,
|
||||
start,
|
||||
stop,
|
||||
clearState,
|
||||
checkStatus,
|
||||
restoreFromStorage,
|
||||
listenForBroadcasts,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -67,10 +67,34 @@ export interface ActivityLogEntry {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ImpersonationResponse {
|
||||
token: string
|
||||
user: AdminUser
|
||||
export interface ImpersonationSession {
|
||||
id: string
|
||||
admin_id: string
|
||||
target_user_id: string
|
||||
target_user?: AdminUser
|
||||
reason: string
|
||||
mfa_method: string
|
||||
started_at: string
|
||||
expires_at: string
|
||||
ended_at: string | null
|
||||
end_reason: string | null
|
||||
actions_count: number
|
||||
}
|
||||
|
||||
export interface ImpersonationStartResponse {
|
||||
session: ImpersonationSession
|
||||
user: AdminUser
|
||||
}
|
||||
|
||||
export interface ImpersonationStatusResponse {
|
||||
active: boolean
|
||||
session?: ImpersonationSession
|
||||
}
|
||||
|
||||
export interface StartImpersonationPayload {
|
||||
reason: string
|
||||
mfa_code: string
|
||||
mfa_method: 'totp' | 'email' | 'backup_code'
|
||||
}
|
||||
|
||||
export interface AdminOrganisationMember {
|
||||
|
||||
@@ -926,17 +926,49 @@ Base path: `/api/v1/admin/`
|
||||
|
||||
### Admin Impersonation
|
||||
|
||||
- `POST /admin/impersonate/{user}` — start impersonating a user (requires `role:super_admin`)
|
||||
- `POST /admin/stop-impersonation` — stop impersonation (requires `auth:sanctum` only, callable by impersonated user)
|
||||
Header-based impersonation with MFA verification. See `AUTH_ARCHITECTURE.md` section 10 for full details.
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
- `POST /admin/impersonate/{user}` — start impersonation (requires `role:super_admin` + MFA)
|
||||
- `POST /admin/stop-impersonation` — stop impersonation (requires `auth:sanctum` only — admin calls without `X-Impersonate-User` header)
|
||||
- `GET /admin/impersonate/status` — check active session (requires `role:super_admin`)
|
||||
- `POST /admin/impersonate/send-mfa-code` — send email verification code to admin (requires `role:super_admin`)
|
||||
|
||||
#### Start Request
|
||||
|
||||
```json
|
||||
{
|
||||
"reason": "Investigating user issue with shift assignments",
|
||||
"mfa_code": "123456",
|
||||
"mfa_method": "totp"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Rules |
|
||||
|-------|------|-------|
|
||||
| `reason` | string | required, min:5, max:500 |
|
||||
| `mfa_code` | string | required |
|
||||
| `mfa_method` | string | required, in: `totp`, `email`, `backup_code` |
|
||||
|
||||
#### Start Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"token": "1|abc123...",
|
||||
"user": { "...AdminUserResource..." },
|
||||
"admin_id": "01JXYZ..."
|
||||
"session": {
|
||||
"id": "01JXYZ...",
|
||||
"admin_id": "01JXYZ...",
|
||||
"target_user_id": "01JXYZ...",
|
||||
"reason": "Investigating user issue",
|
||||
"mfa_method": "totp",
|
||||
"started_at": "2026-04-16T12:00:00+00:00",
|
||||
"expires_at": "2026-04-16T13:00:00+00:00",
|
||||
"ended_at": null,
|
||||
"end_reason": null,
|
||||
"actions_count": 0
|
||||
},
|
||||
"user": { "...AdminUserResource..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -951,12 +983,33 @@ Base path: `/api/v1/admin/`
|
||||
}
|
||||
```
|
||||
|
||||
#### Status Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"active": true,
|
||||
"session": { "...ImpersonationSessionResource..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Impersonation Header
|
||||
|
||||
During an active session, the frontend sends `X-Impersonate-User: {target_user_id}` on every request. The `HandleImpersonation` middleware validates the header against the cached session and swaps auth context.
|
||||
|
||||
#### Business Rules
|
||||
|
||||
- Admin must have MFA enabled (403 if not)
|
||||
- Cannot impersonate another super_admin (403)
|
||||
- Impersonation token has name `impersonation-by-{admin_id}`
|
||||
- Admin ID is cached for 4 hours at key `impersonation:{token_id}`
|
||||
- Activity log records both start (`admin.impersonation.started`) and stop (`admin.impersonation.stopped`)
|
||||
- Cannot nest impersonation sessions (403)
|
||||
- Cannot impersonate a user already being impersonated (403)
|
||||
- MFA code verified against admin's own MFA (TOTP, email, or backup code)
|
||||
- Sessions expire after 60 minutes (sliding TTL, extended on each request)
|
||||
- IP pinning: session terminated if admin's IP changes
|
||||
- Sensitive routes blocked during impersonation (auth/*, me/*, admin/impersonate/*)
|
||||
- All activity during session tagged with `impersonated_by` in properties
|
||||
- Immutable audit trail in `impersonation_sessions` table
|
||||
|
||||
## Email Settings (org admin)
|
||||
|
||||
|
||||
@@ -279,3 +279,124 @@ Platform admins (`super_admin`) can force-disable MFA for any user via `POST /ad
|
||||
| `app/Http/Controllers/Api/V1/Auth/MfaSetupController.php` | Authenticated MFA setup/disable/status endpoints |
|
||||
| `app/Http/Controllers/Api/V1/Auth/MfaVerifyController.php` | Login-flow MFA verification (unauthenticated) |
|
||||
| `app/Http/Controllers/Api/V1/Auth/TrustedDeviceController.php` | Trusted device management |
|
||||
|
||||
---
|
||||
|
||||
## 10. Impersonation
|
||||
|
||||
### 10.1 Overview
|
||||
|
||||
Platform admins (`super_admin`) can impersonate other users to investigate issues. The system uses a **header-based approach** — the admin's own httpOnly cookie session is never modified. Instead, the frontend sends an `X-Impersonate-User` header, and a middleware swaps the auth context per-request.
|
||||
|
||||
### 10.2 Security Controls
|
||||
|
||||
| Control | Implementation |
|
||||
|---------|---------------|
|
||||
| **MFA at start** | Admin must provide a valid TOTP, email, or backup code to begin impersonation |
|
||||
| **Admin MFA required** | Admin's MFA must be enabled (`mfa_enabled = true`) |
|
||||
| **No super_admin targets** | Cannot impersonate another super_admin |
|
||||
| **No nesting** | Cannot start a second impersonation while one is active |
|
||||
| **No double-impersonation** | Cannot impersonate a user already being impersonated by another admin |
|
||||
| **IP pinning** | If the admin's IP changes mid-session, the session is terminated |
|
||||
| **Sensitive route blocking** | Auth, MFA, password, email, profile, and impersonation routes are blocked during impersonation |
|
||||
| **Sliding TTL** | Sessions auto-expire after 60 minutes; each request extends the window |
|
||||
| **Immutable audit** | Every session is recorded in `impersonation_sessions` with no soft deletes |
|
||||
|
||||
### 10.3 Flow
|
||||
|
||||
```
|
||||
Admin (with httpOnly cookie) API
|
||||
│ │
|
||||
│ POST /admin/impersonate/{user} │
|
||||
│ { reason, mfa_code, mfa_method } │
|
||||
│ ──────────────────────────────────────────►│
|
||||
│ │ ── Verify admin is super_admin
|
||||
│ │ ── Verify admin has MFA enabled
|
||||
│ │ ── Verify target is not super_admin
|
||||
│ │ ── Check no active session (admin or target)
|
||||
│ │ ── Verify MFA code
|
||||
│ │ ── Create ImpersonationSession (DB)
|
||||
│ │ ── Store session ID in cache (60 min TTL)
|
||||
│ ◄── session + user data ─────────────────│
|
||||
│ │
|
||||
│ GET /any-route │
|
||||
│ Cookie: crewli_app_token=... │
|
||||
│ X-Impersonate-User: {target_user_id} │
|
||||
│ ──────────────────────────────────────────►│
|
||||
│ │ ── CookieBearerToken: inject admin auth
|
||||
│ │ ── auth:sanctum: validate admin token
|
||||
│ │ ── HandleImpersonation middleware:
|
||||
│ │ 1. Read X-Impersonate-User header
|
||||
│ │ 2. Check sensitive route block list
|
||||
│ │ 3. Validate cache session (admin+target)
|
||||
│ │ 4. Check IP matches session
|
||||
│ │ 5. Extend sliding TTL
|
||||
│ │ 6. Store impersonator in request attributes
|
||||
│ │ 7. Swap auth: setUser(targetUser)
|
||||
│ │ 8. Increment actions_count
|
||||
│ │ ── Controller sees targetUser as auth user
|
||||
│ ◄── response (as target user) ───────────│
|
||||
│ │
|
||||
│ POST /admin/stop-impersonation │
|
||||
│ (NO X-Impersonate-User header) │
|
||||
│ ──────────────────────────────────────────►│
|
||||
│ │ ── Admin auth from cookie
|
||||
│ │ ── End session (DB + cache)
|
||||
│ ◄── admin user data ─────────────────────│
|
||||
```
|
||||
|
||||
### 10.4 Blocked Routes During Impersonation
|
||||
|
||||
The `HandleImpersonation` middleware blocks these route prefixes when the `X-Impersonate-User` header is present:
|
||||
|
||||
- `auth/password` — password management
|
||||
- `auth/logout` — would affect admin's session
|
||||
- `auth/mfa` — MFA setup/verify/status
|
||||
- `auth/trusted-devices` — device trust management
|
||||
- `me/profile` — profile updates
|
||||
- `me/change-password` — password changes
|
||||
- `me/change-email` — email changes
|
||||
- `admin/impersonate` — no nesting via API
|
||||
- `verify-email-change` — email verification
|
||||
|
||||
### 10.5 Activity Log Integration
|
||||
|
||||
During an active impersonation session, `AppServiceProvider` hooks into `Activity::saving()` and automatically adds:
|
||||
- `impersonated_by.user_id` — the admin's user ID
|
||||
- `impersonated_by.name` — the admin's full name
|
||||
- `impersonated_by.email` — the admin's email
|
||||
- `impersonation_session_id` — the session ID
|
||||
|
||||
This applies to **all** activity log entries, not just impersonation-specific events.
|
||||
|
||||
### 10.6 Database Table
|
||||
|
||||
**`impersonation_sessions`** — immutable audit table (no soft deletes)
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | ULID | PK |
|
||||
| `admin_id` | ULID FK | → users |
|
||||
| `target_user_id` | ULID FK | → users |
|
||||
| `reason` | string | Admin-provided reason for impersonation |
|
||||
| `mfa_method` | string(20) | Which MFA method was used to verify |
|
||||
| `ip_address` | string(45) | Admin's IP at session start |
|
||||
| `user_agent` | text nullable | Admin's user agent |
|
||||
| `started_at` | timestamp | Session start time |
|
||||
| `ended_at` | timestamp nullable | NULL = still active |
|
||||
| `expires_at` | timestamp | Auto-expiry time (sliding, 60 min) |
|
||||
| `end_reason` | string(50) nullable | manual, expired, ip_changed, admin_kill_all |
|
||||
| `actions_count` | unsigned int | Number of API requests made during session |
|
||||
|
||||
**Indexes:** `(admin_id, ended_at)`, `(target_user_id, ended_at)`, `(started_at)`
|
||||
|
||||
### 10.7 Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `app/Services/ImpersonationService.php` | Session lifecycle, MFA verification, cache management |
|
||||
| `app/Http/Middleware/HandleImpersonation.php` | Per-request header validation, auth swap, route blocking |
|
||||
| `app/Http/Controllers/Api/V1/Admin/AdminImpersonationController.php` | Start, stop, status, send-mfa-code endpoints |
|
||||
| `app/Http/Requests/Admin/StartImpersonationRequest.php` | Validation for start request |
|
||||
| `app/Models/ImpersonationSession.php` | Eloquent model with `HasUlids`, `scopeActive()` |
|
||||
| `app/Http/Resources/Admin/ImpersonationSessionResource.php` | API resource for session data |
|
||||
|
||||
@@ -330,6 +330,29 @@ scopeFestivals() // WHERE event_type IN ('festival', 'series')
|
||||
|
||||
---
|
||||
|
||||
### `impersonation_sessions`
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ---------------- | ------------------ | ---------------------------------------- |
|
||||
| `id` | ULID | PK |
|
||||
| `admin_id` | ULID FK | → users |
|
||||
| `target_user_id` | ULID FK | → users |
|
||||
| `reason` | string | Admin-provided reason |
|
||||
| `mfa_method` | string(20) | totp, email, or backup_code |
|
||||
| `ip_address` | string(45) | Admin's IP at start |
|
||||
| `user_agent` | text nullable | Admin's user agent |
|
||||
| `started_at` | timestamp | |
|
||||
| `ended_at` | timestamp nullable | NULL = still active |
|
||||
| `expires_at` | timestamp | Sliding 60-min TTL |
|
||||
| `end_reason` | string(50) nullable| manual, expired, ip_changed, admin_kill_all |
|
||||
| `actions_count` | unsigned int | API requests made during session |
|
||||
|
||||
**Relations:** `belongsTo` User (admin), `belongsTo` User (target)
|
||||
**Indexes:** `(admin_id, ended_at)`, `(target_user_id, ended_at)`, `(started_at)`
|
||||
**Soft delete:** no — immutable audit table
|
||||
|
||||
---
|
||||
|
||||
## 3.5.2 Locations
|
||||
|
||||
> Locations are event-scoped and reusable across sections within an event.
|
||||
|
||||
Reference in New Issue
Block a user