Hand impersonation over by signed link, not by a cookie that cannot cross hosts

feat/operator-identity
nexxo 2026-07-28 12:02:22 +02:00
parent 73dec7f685
commit 26c84e3695
3 changed files with 118 additions and 77 deletions

View File

@ -4,48 +4,70 @@ namespace App\Http\Controllers;
use App\Models\Customer; use App\Models\Customer;
use App\Models\Operator; use App\Models\Operator;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth; 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 * Looking at a customer's portal as that customer.
* inspect their portal during an incident, then return to the admin session.
* *
* Both guards are named explicitly throughout, never the ambient default: the * Not a shared cookie. Once the console has a hostname of its own, a session
* operator and the customer are two different models on two different guards * cookie set on the console host never reaches the portal host SESSION_DOMAIN
* now, and this is exactly the code that swaps which one is "the" signed-in * is null and cookies are host-bound. So the handover is a signed, single-use
* user for the request. The full signed-link rewrite (for hosts that do not * link: it works regardless of cookie scope, it expires, and it leaves a record
* share a cookie at all) is a separate piece of work; this keeps today's * of who looked at whom.
* same-session handover correct across the split identity in the meantime.
*/ */
class ImpersonationController extends Controller 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 public function start(Customer $customer): RedirectResponse
{ {
$this->authorize('customers.impersonate'); $this->authorize('customers.impersonate');
$user = $customer->ensureUser();
session(['impersonator_id' => Auth::guard('operator')->id()]); $operator = Auth::guard('operator')->user();
Auth::guard('web')->login($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 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 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) { return redirect()->to(\App\Support\AdminArea::home());
// 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');
} }
} }

View File

@ -192,6 +192,11 @@ Route::middleware('guest')->group(function () {
Route::get('/two-factor-challenge', TwoFactorChallenge::class)->name('two-factor.login'); 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 // Customer portal — each sidebar tab is a full-page class-based Livewire
// component (R1/R2); paths are English (R13). // component (R1/R2); paths are English (R13).
Route::middleware(['auth', 'customer.active'])->group(function () { Route::middleware(['auth', 'customer.active'])->group(function () {

View File

@ -2,64 +2,78 @@
use App\Models\Customer; use App\Models\Customer;
use App\Models\Operator; 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('hands over a signed link instead of relying on a shared cookie', function () {
$operator = Operator::factory()->role('Owner')->create();
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]);
$customer = Customer::factory()->create(); $customer = Customer::factory()->create();
$this->actingAs($user) $response = $this->actingAs($operator, 'operator')
->post(route('admin.impersonate', $customer->uuid)) ->post(route('admin.impersonate', $customer));
->assertForbidden();
// 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(); $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();
}); });