feat: add user impersonation service

This commit is contained in:
idevakk
2025-11-17 10:44:19 -08:00
parent f60c986e07
commit a7029b5f57
21 changed files with 1343 additions and 6 deletions

View File

@@ -9,7 +9,9 @@ use App\Filament\Resources\UserResource\Pages\ListUsers;
use App\Filament\Resources\UserResource\RelationManagers\LogsRelationManager;
use App\Filament\Resources\UserResource\RelationManagers\UsageLogsRelationManager;
use App\Models\User;
use App\Services\ImpersonationService;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
@@ -49,6 +51,12 @@ class UserResource extends Resource
->email()
->required()
->maxLength(255),
TextInput::make('password')
->password()
->required()
->minLength(6)
->maxLength(255)
->visibleOn('create'),
TextInput::make('email_verified_at')
->label('Email Verification Status')
->disabled()
@@ -149,8 +157,65 @@ class UserResource extends Resource
])
->recordActions([
EditAction::make(),
Action::make('impersonate')
->label('Login as User')
->icon('heroicon-o-arrow-right-on-rectangle')
->color('warning')
->requiresConfirmation()
->modalHeading('Start User Impersonation')
->modalDescription('This will open a new tab where you will be logged in as this user. All actions will be logged for security. Your admin panel will remain open in this tab.')
->modalSubmitActionLabel('Start Impersonation')
->modalCancelActionLabel('Cancel')
->visible(fn (User $record): bool => auth()->user()?->isSuperAdmin() &&
$record->isNormalUser() &&
! app(ImpersonationService::class)->isImpersonating()
)
->url(function (User $record): string {
$admin = auth()->user();
$impersonationService = app(ImpersonationService::class);
if (! $impersonationService->canImpersonate($admin, $record)) {
Notification::make()
->title('Impersonation Failed')
->body('Unable to start impersonation. Please check permissions and try again.')
->danger()
->send();
return '#';
}
// Return impersonation URL
return route('impersonation.start', $record);
})
->openUrlInNewTab(),
])
->toolbarActions([
Action::make('stopImpersonation')
->label('Stop Impersonating')
->icon('heroicon-o-arrow-left-on-rectangle')
->color('danger')
->visible(fn (): bool => app(ImpersonationService::class)->isImpersonating())
->action(function () {
$impersonationService = app(ImpersonationService::class);
if (! $impersonationService->stopImpersonation(request())) {
Notification::make()
->title('Failed to Stop Impersonation')
->body('Unable to stop impersonation session.')
->danger()
->send();
return;
}
Notification::make()
->title('Impersonation Ended')
->body('You have been returned to your admin account.')
->success()
->send();
return redirect()->to(\Filament\Pages\Dashboard::getUrl());
}),
BulkActionGroup::make([
DeleteBulkAction::make(),
BulkAction::make('updateLevel')

View File

@@ -6,7 +6,9 @@ use App\Filament\Resources\UserResource;
use App\Models\User;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Facades\Response;
class EditUser extends EditRecord
@@ -17,6 +19,25 @@ class EditUser extends EditRecord
{
return [
DeleteAction::make(),
Action::make('mark_email_verified')
->label('Mark Email as Verified')
->icon(Heroicon::OutlinedEnvelope)
->action(function (User $user) {
if (! $user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
Notification::make('email_verified_successfully')
->title('Email Verified Successfully')
->icon(Heroicon::OutlinedEnvelope)
->success()
->send();
} else {
Notification::make('email_already_verified')
->title('Email Already Verified')
->icon(Heroicon::OutlinedEnvelope)
->warning()
->send();
}
}),
Action::make('download_report')
->label('Download User Report')
->icon('heroicon-o-user')

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\ImpersonationService;
use Filament\Notifications\Notification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
final class ImpersonationController extends Controller
{
public function __construct(
private readonly ImpersonationService $impersonationService
) {}
/**
* Start impersonating a user.
*/
public function start(Request $request, User $user)
{
$admin = Auth::user();
if (! $admin || ! $admin->isSuperAdmin()) {
Notification::make()
->title('Access Denied')
->body('Only SuperAdmin users can impersonate other users.')
->danger()
->send();
return redirect()->back();
}
if (! $this->impersonationService->startImpersonation($admin, $user, $request)) {
Notification::make()
->title('Impersonation Failed')
->body('Unable to start impersonation. Please check permissions and try again.')
->danger()
->send();
return redirect()->back();
}
Notification::make()
->title('Impersonation Started')
->body("You are now logged in as {$user->name}.")
->success()
->send();
// Redirect to user dashboard (assuming you have a dashboard route)
return redirect()->route('dashboard');
}
/**
* Stop impersonating the current user.
*/
public function stop(Request $request)
{
if (! $this->impersonationService->isImpersonating()) {
return redirect()->to(\Filament\Pages\Dashboard::getUrl());
}
$impersonator = $this->impersonationService->getCurrentImpersonator();
if (! $this->impersonationService->stopImpersonation($request)) {
Notification::make()
->title('Failed to Stop Impersonation')
->body('Unable to stop impersonation session.')
->danger()
->send();
return redirect()->back();
}
Notification::make()
->title('Impersonation Ended')
->body('You have been returned to your admin account.')
->success()
->send();
return redirect()->to(\Filament\Pages\Dashboard::getUrl());
}
/**
* Show impersonation status (AJAX endpoint).
*/
public function status(Request $request)
{
if (! $this->impersonationService->isImpersonating()) {
return response()->json([
'is_impersonating' => false,
]);
}
$impersonator = $this->impersonationService->getCurrentImpersonator();
$target = Auth::user();
$remainingMinutes = $this->impersonationService->getRemainingMinutes();
return response()->json([
'is_impersonating' => true,
'impersonator' => [
'name' => $impersonator?->name,
'email' => $impersonator?->email,
],
'target' => [
'name' => $target?->name,
'email' => $target?->email,
],
'remaining_minutes' => $remainingMinutes,
'expires_soon' => $remainingMinutes <= 5,
]);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\ImpersonationService;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
final class ImpersonationMiddleware
{
public function __construct(
private readonly ImpersonationService $impersonationService
) {}
public function handle(Request $request, Closure $next): Response
{
// Check if impersonation has expired
if ($this->impersonationService->isImpersonating() &&
$this->impersonationService->isImpersonationExpired()) {
$this->impersonationService->stopImpersonation($request);
// Redirect to admin dashboard with expired message
return redirect()->to(\Filament\Pages\Dashboard::getUrl())->with('impersonation_expired', 'Your impersonation session has expired.');
}
// Share impersonation data with all views
if ($this->impersonationService->isImpersonating()) {
$impersonator = $this->impersonationService->getCurrentImpersonator();
$remainingMinutes = $this->impersonationService->getRemainingMinutes();
$startTime = $this->impersonationService->getImpersonationStartTime();
view()->share('isImpersonating', true);
view()->share('impersonator', $impersonator);
view()->share('impersonationTarget', Auth::user());
view()->share('impersonationRemainingMinutes', $remainingMinutes);
view()->share('impersonationStartTime', $startTime);
} else {
view()->share('isImpersonating', false);
view()->share('impersonator', null);
view()->share('impersonationTarget', null);
view()->share('impersonationRemainingMinutes', 0);
view()->share('impersonationStartTime', null);
}
return $next($request);
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class ImpersonationLog extends Model
{
use HasFactory;
protected $fillable = [
'admin_id',
'target_user_id',
'start_time',
'end_time',
'ip_address',
'user_agent',
'status',
'pages_visited',
'actions_taken',
];
protected $casts = [
'start_time' => 'datetime',
'end_time' => 'datetime',
'pages_visited' => 'array',
'actions_taken' => 'array',
];
public function admin(): BelongsTo
{
return $this->belongsTo(User::class, 'admin_id');
}
public function targetUser(): BelongsTo
{
return $this->belongsTo(User::class, 'target_user_id');
}
public function getDurationInSecondsAttribute(): ?int
{
if (! $this->end_time) {
return null;
}
return $this->start_time->diffInSeconds($this->end_time);
}
public function getDurationInMinutesAttribute(): ?int
{
$seconds = $this->duration_in_seconds;
return $seconds ? (int) ceil($seconds / 60) : null;
}
public function isActive(): bool
{
return $this->status === 'active' && is_null($this->end_time);
}
public function isCompleted(): bool
{
return $this->status === 'completed' && ! is_null($this->end_time);
}
public function isForceTerminated(): bool
{
return $this->status === 'force_terminated';
}
public function scopeActive($query)
{
return $query->where('status', 'active')->whereNull('end_time');
}
public function scopeCompleted($query)
{
return $query->where('status', 'completed');
}
public function scopeForAdmin($query, User $admin)
{
return $query->where('admin_id', $admin->id);
}
public function scopeForTarget($query, User $target)
{
return $query->where('target_user_id', $target->id);
}
public function scopeToday($query)
{
return $query->whereDate('start_time', today());
}
public function scopeThisWeek($query)
{
return $query->whereBetween('start_time', [now()->startOfWeek(), now()->endOfWeek()]);
}
public function scopeThisMonth($query)
{
return $query->whereBetween('start_time', [now()->startOfMonth(), now()->endOfMonth()]);
}
public function addPageVisited(string $page): void
{
$pages = $this->pages_visited ?? [];
$pages[] = [
'page' => $page,
'timestamp' => now()->toISOString(),
];
$this->update(['pages_visited' => $pages]);
}
public function addActionTaken(string $action, array $data = []): void
{
$actions = $this->actions_taken ?? [];
$actions[] = [
'action' => $action,
'data' => $data,
'timestamp' => now()->toISOString(),
];
$this->update(['actions_taken' => $actions]);
}
}

View File

@@ -139,4 +139,14 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
{
return $this->hasMany(UsageLog::class);
}
public function impersonationLogs()
{
return $this->hasMany(ImpersonationLog::class, 'admin_id');
}
public function impersonationTargets()
{
return $this->hasMany(ImpersonationLog::class, 'target_user_id');
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\enum\UserLevel;
use App\Models\User;
use App\Services\ImpersonationService;
use Illuminate\Auth\Access\Response;
final class ImpersonationPolicy
{
public function __construct(
private readonly ImpersonationService $impersonationService
) {}
public function start(User $admin, User $target): Response
{
// Only SUPERADMIN can impersonate
if ($admin->user_level !== UserLevel::SUPERADMIN) {
return Response::deny('Only SUPERADMIN users can impersonate other users.');
}
// Cannot impersonate other SUPERADMIN users
if ($target->user_level === UserLevel::SUPERADMIN) {
return Response::deny('Cannot impersonate SUPERADMIN users.');
}
// Cannot impersonate self
if ($admin->id === $target->id) {
return Response::deny('Cannot impersonate yourself.');
}
// Can only impersonate NORMALUSER
if ($target->user_level !== UserLevel::NORMALUSER) {
return Response::deny('Can only impersonate NORMALUSER accounts.');
}
// Check if admin is already impersonating someone
if ($this->impersonationService->isImpersonating()) {
return Response::deny('You are already impersonating another user. Please stop the current impersonation first.');
}
// Rate limiting: Check if admin has too many recent impersonation attempts
$recentAttempts = $admin->impersonationLogs()
->where('start_time', '>', now()->subHours(1))
->count();
if ($recentAttempts >= 10) {
return Response::deny('Too many impersonation attempts. Please try again later.');
}
return Response::allow();
}
public function stop(User $user): Response
{
// Only users who are currently impersonating can stop impersonation
if (! $this->impersonationService->isImpersonating()) {
return Response::deny('No active impersonation session found.');
}
// The original admin user must be the one stopping the impersonation
$impersonator = $this->impersonationService->getCurrentImpersonator();
if (! $impersonator || $impersonator->id !== $user->id) {
return Response::deny('You are not authorized to stop this impersonation session.');
}
return Response::allow();
}
public function viewAny(User $user): Response
{
// Only SUPERADMIN can view impersonation logs
if ($user->user_level !== UserLevel::SUPERADMIN) {
return Response::deny('Only SUPERADMIN users can view impersonation logs.');
}
return Response::allow();
}
public function view(User $user, $impersonationLog): Response
{
// Only SUPERADMIN can view specific impersonation logs
if ($user->user_level !== UserLevel::SUPERADMIN) {
return Response::deny('Only SUPERADMIN users can view impersonation logs.');
}
// Users can only view logs where they are the admin
if ($impersonationLog->admin_id !== $user->id) {
return Response::deny('You can only view your own impersonation logs.');
}
return Response::allow();
}
}

View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\enum\UserLevel;
use App\Models\ImpersonationLog;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
final class ImpersonationService
{
private const IMPERSONATION_SESSION_KEY = 'impersonation';
private const IMPERSONATION_START_TIME_KEY = 'impersonation_start_time';
private const ORIGINAL_USER_KEY = 'original_admin_user_id';
private const IMPERSONATION_TIMEOUT = 30; // minutes
public function canImpersonate(User $admin, User $target): bool
{
// Only SUPERADMIN can impersonate
if ($admin->level !== UserLevel::SUPERADMIN) {
return false;
}
// Cannot impersonate other SUPERADMIN users
if ($target->level === UserLevel::SUPERADMIN) {
return false;
}
// Cannot impersonate self
if ($admin->id === $target->id) {
return false;
}
// Can only impersonate NORMALUSER
if ($target->level !== UserLevel::NORMALUSER) {
return false;
}
// Check if admin is already impersonating someone
if ($this->isImpersonating()) {
return false;
}
return true;
}
public function isImpersonating(): bool
{
return Session::has(self::IMPERSONATION_SESSION_KEY) &&
Session::has(self::ORIGINAL_USER_KEY);
}
public function getCurrentImpersonator(): ?User
{
if (! $this->isImpersonating()) {
return null;
}
$originalUserId = Session::get(self::ORIGINAL_USER_KEY);
return User::find($originalUserId);
}
public function getImpersonationStartTime(): ?Carbon
{
if (! $this->isImpersonating()) {
return null;
}
return Session::get(self::IMPERSONATION_START_TIME_KEY);
}
public function isImpersonationExpired(): bool
{
if (! $this->isImpersonating()) {
return false;
}
$startTime = $this->getImpersonationStartTime();
if (! $startTime) {
return true;
}
return $startTime->diffInMinutes(now()) > self::IMPERSONATION_TIMEOUT;
}
public function startImpersonation(User $admin, User $target, Request $request): bool
{
if (! $this->canImpersonate($admin, $target)) {
return false;
}
// Store original admin user info
Session::put(self::ORIGINAL_USER_KEY, $admin->id);
Session::put(self::IMPERSONATION_START_TIME_KEY, now());
// Log the impersonation start
$impersonationLog = ImpersonationLog::create([
'admin_id' => $admin->id,
'target_user_id' => $target->id,
'start_time' => now(),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'status' => 'active',
]);
Session::put(self::IMPERSONATION_SESSION_KEY, $impersonationLog->id);
// Log out the admin and log in as target user
Auth::logout();
Auth::login($target);
// Regenerate session for security
Session::regenerate(true);
return true;
}
public function stopImpersonation(Request $request): bool
{
if (! $this->isImpersonating()) {
return false;
}
$admin = $this->getCurrentImpersonator();
$target = Auth::user();
$impersonationLogId = Session::get(self::IMPERSONATION_SESSION_KEY);
// Update the impersonation log
if ($impersonationLogId) {
$log = ImpersonationLog::find($impersonationLogId);
if ($log) {
$log->update([
'end_time' => now(),
'status' => 'completed',
]);
}
}
// Clear impersonation session data
Session::forget([
self::IMPERSONATION_SESSION_KEY,
self::IMPERSONATION_START_TIME_KEY,
self::ORIGINAL_USER_KEY,
]);
// Log out target user and log back in as admin
Auth::logout();
if ($admin) {
Auth::login($admin);
}
// Regenerate session for security
Session::regenerate(true);
return true;
}
public function forceStopAllImpersonations(): void
{
// Force end any active impersonation sessions in the database
ImpersonationLog::where('status', 'active')
->whereNull('end_time')
->update([
'end_time' => now(),
'status' => 'force_terminated',
]);
}
public function getRemainingMinutes(): int
{
if (! $this->isImpersonating()) {
return 0;
}
$startTime = $this->getImpersonationStartTime();
if (! $startTime) {
return 0;
}
$elapsed = $startTime->diffInMinutes(now());
$remaining = self::IMPERSONATION_TIMEOUT - $elapsed;
return (int) max(0, $remaining);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class ImpersonationBanner extends Component
{
/**
* Create a new component instance.
*/
public function __construct()
{
//
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.impersonation-banner');
}
}