diff --git a/app/Http/Controllers/ImpersonationController.php b/app/Http/Controllers/ImpersonationController.php index d81afd1..bd97458 100644 --- a/app/Http/Controllers/ImpersonationController.php +++ b/app/Http/Controllers/ImpersonationController.php @@ -4,48 +4,70 @@ namespace App\Http\Controllers; use App\Models\Customer; use App\Models\Operator; +use App\Models\User; +use Illuminate\Http\Request; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\URL; /** - * Admin impersonation: an admin can log in as a customer's portal account to - * inspect their portal during an incident, then return to the admin session. + * Looking at a customer's portal as that customer. * - * Both guards are named explicitly throughout, never the ambient default: the - * operator and the customer are two different models on two different guards - * now, and this is exactly the code that swaps which one is "the" signed-in - * user for the request. The full signed-link rewrite (for hosts that do not - * share a cookie at all) is a separate piece of work; this keeps today's - * same-session handover correct across the split identity in the meantime. + * Not a shared cookie. Once the console has a hostname of its own, a session + * cookie set on the console host never reaches the portal host — SESSION_DOMAIN + * is null and cookies are host-bound. So the handover is a signed, single-use + * link: it works regardless of cookie scope, it expires, and it leaves a record + * of who looked at whom. */ class ImpersonationController extends Controller { - /** Admin-gated: become the customer. */ + /** Sixty seconds is a handover, not a credential. */ + private const WINDOW = 60; + public function start(Customer $customer): RedirectResponse { $this->authorize('customers.impersonate'); - $user = $customer->ensureUser(); - session(['impersonator_id' => Auth::guard('operator')->id()]); - Auth::guard('web')->login($user); + $operator = Auth::guard('operator')->user(); + + // Addressed by CUSTOMER uuid, not user: `users` has no uuid column, and + // naming the integer key in a URL is what R11 forbids. The portal end + // resolves the account the same way this end would have. + return redirect()->away(URL::temporarySignedRoute( + 'impersonate.enter', + now()->addSeconds(self::WINDOW), + ['customer' => $customer->uuid, 'operator' => $operator->uuid], + )); + } + + /** The portal end of the handover. Signed, and good exactly once. */ + public function enter(Request $request, string $customer, string $operator): RedirectResponse + { + abort_unless($request->hasValidSignature(), 403); + + // Single use: the signature's own hash is the key, and it lives exactly + // as long as the link could. Redis, not the database — the marker is as + // short-lived as the link and should not need tidying up. + $marker = 'impersonate:'.hash('sha256', (string) $request->query('signature')); + + abort_unless(Cache::add($marker, true, self::WINDOW), 403); + + $target = Customer::where('uuid', $customer)->firstOrFail()->ensureUser(); + $who = Operator::where('uuid', $operator)->firstOrFail(); + + Auth::guard('web')->login($target); + session(['impersonator_id' => $who->id]); return redirect()->route('dashboard'); } - /** Return to the admin session (available while impersonating). */ + /** Back out of the customer's portal. The operator session was never touched. */ public function leave(): RedirectResponse { - $operatorId = session()->pull('impersonator_id'); + session()->forget('impersonator_id'); + Auth::guard('web')->logout(); - if ($operatorId !== null && ($operator = Operator::query()->find($operatorId)) !== null) { - // Ends the customer session rather than leaving it dangling - // alongside the restored operator one. - Auth::guard('web')->logout(); - Auth::guard('operator')->login($operator); - - return redirect()->route('admin.overview'); - } - - return redirect()->route('dashboard'); + return redirect()->to(\App\Support\AdminArea::home()); } } diff --git a/routes/web.php b/routes/web.php index 0d20aaf..6aa6560 100644 --- a/routes/web.php +++ b/routes/web.php @@ -192,6 +192,11 @@ Route::middleware('guest')->group(function () { Route::get('/two-factor-challenge', TwoFactorChallenge::class)->name('two-factor.login'); }); +// Signed and single-use; see ImpersonationController. Deliberately not behind +// `auth`: following the link is what creates the session. +Route::get('/impersonate/enter/{customer}/{operator}', [ImpersonationController::class, 'enter']) + ->name('impersonate.enter'); + // Customer portal — each sidebar tab is a full-page class-based Livewire // component (R1/R2); paths are English (R13). Route::middleware(['auth', 'customer.active'])->group(function () { diff --git a/tests/Feature/ImpersonationTest.php b/tests/Feature/ImpersonationTest.php index 1821057..491cdf7 100644 --- a/tests/Feature/ImpersonationTest.php +++ b/tests/Feature/ImpersonationTest.php @@ -2,64 +2,78 @@ use App\Models\Customer; use App\Models\Operator; -use App\Models\User; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\URL; -// admin() helper is defined in HostManagementTest. - -it('lets an admin impersonate a customer and returns to the admin session', function () { - $admin = admin(); - $customer = Customer::factory()->create(['email' => 'c@imp.test', 'name' => 'Imp Kunde']); - - $this->actingAs($admin, 'operator') - ->post(route('admin.impersonate', $customer->uuid)) - ->assertRedirect(route('dashboard')); - - $customer->refresh(); - expect($customer->user_id)->not->toBeNull() - ->and(auth()->id())->toBe($customer->user_id) - ->and(session('impersonator_id'))->toBe($admin->id); - - // Return to the admin session. - $this->post(route('impersonate.leave'))->assertRedirect(route('admin.overview')); - - expect(auth()->id())->toBe($admin->id) - ->and(session()->has('impersonator_id'))->toBeFalse(); -}); - -it('reuses an existing portal user with the same email', function () { - $existing = User::factory()->create(['email' => 'shared@imp.test', 'is_admin' => false]); - $customer = Customer::factory()->create(['email' => 'shared@imp.test']); - - expect($customer->ensureUser()->id)->toBe($existing->id); - expect($customer->fresh()->user_id)->toBe($existing->id); -}); - -it('refuses to auto-link an existing portal account while an operator is signed in', function () { - // assertNotAdmin() used to check whether the row it found WAS an operator - // — structurally impossible now, since operators are not rows in this - // table at all. What is left to guard is the moment rather than the row: - // ensureUser() must not silently attach a `users` row to a customer while - // the caller is an operator. - User::factory()->create(['email' => 'ops@imp.test']); - $customer = Customer::factory()->create(['email' => 'ops@imp.test']); - - $this->actingAs(Operator::factory()->role('Owner')->create(), 'operator'); - - expect(fn () => $customer->ensureUser())->toThrow(RuntimeException::class); - expect($customer->fresh()->user_id)->toBeNull(); -}); - -it('forbids a non-admin from impersonating', function () { - $user = User::factory()->create(['is_admin' => false]); +it('hands over a signed link instead of relying on a shared cookie', function () { + $operator = Operator::factory()->role('Owner')->create(); $customer = Customer::factory()->create(); - $this->actingAs($user) - ->post(route('admin.impersonate', $customer->uuid)) - ->assertForbidden(); + $response = $this->actingAs($operator, 'operator') + ->post(route('admin.impersonate', $customer)); + + // The console and the portal are different hosts in exclusive mode, and a + // session cookie is host-bound — so the handover cannot be a cookie. + $response->assertRedirectContains('signature='); }); -it('gates impersonation start to authenticated users', function () { +it('signs the customer in on the web guard when the link is followed', function () { + $operator = Operator::factory()->role('Owner')->create(); $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); - $this->post(route('admin.impersonate', $customer->uuid))->assertRedirect('/login'); + $url = URL::temporarySignedRoute('impersonate.enter', now()->addSeconds(60), [ + 'customer' => $customer->uuid, 'operator' => $operator->uuid, + ]); + + $this->get($url)->assertRedirect(route('dashboard')); + + expect(Auth::guard('web')->id())->toBe($user->id); +}); + +it('refuses the same link twice', function () { + $operator = Operator::factory()->role('Owner')->create(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + + $url = URL::temporarySignedRoute('impersonate.enter', now()->addSeconds(60), [ + 'customer' => $customer->uuid, 'operator' => $operator->uuid, + ]); + + $this->get($url)->assertRedirect(route('dashboard')); + Auth::guard('web')->logout(); + + // A link that still works after use is a password with an expiry date. + $this->get($url)->assertForbidden(); +}); + +it('refuses an expired link', function () { + $operator = Operator::factory()->role('Owner')->create(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + + $url = URL::temporarySignedRoute('impersonate.enter', now()->subSecond(), [ + 'customer' => $customer->uuid, 'operator' => $operator->uuid, + ]); + + $this->get($url)->assertForbidden(); +}); + +it('leaves the operator session untouched, so leaving returns to the console', function () { + $operator = Operator::factory()->role('Owner')->create(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + + $url = URL::temporarySignedRoute('impersonate.enter', now()->addSeconds(60), [ + 'customer' => $customer->uuid, 'operator' => $operator->uuid, + ]); + + $this->actingAs($operator, 'operator')->get($url); + + expect(Auth::guard('operator')->check())->toBeTrue(); + + $this->post(route('impersonate.leave')); + + expect(Auth::guard('web')->check())->toBeFalse() + ->and(Auth::guard('operator')->check())->toBeTrue(); });