Sign the impersonation link against the portal's own host, not the console's

feat/operator-identity
nexxo 2026-07-28 12:24:53 +02:00
parent bdb9c0a04a
commit 88d9a66f44
2 changed files with 71 additions and 8 deletions

View File

@ -31,14 +31,41 @@ class ImpersonationController extends Controller
$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],
));
// impersonate.enter carries no domain of its own — a portal route,
// reached wherever the portal already answers. Its SIGNATURE is
// computed over the full absolute URL: UrlGenerator::signedRoute()
// hashes the generated URL string, host included, and
// hasCorrectSignature() re-hashes $request->url() — the ACTUAL request
// URL — on the way back. Left alone, "the generated URL" means
// whichever host issued THIS request: the console's, once it has an
// exclusive hostname of its own. RestrictAdminHost then 404s every
// non-console route reached through the console's exclusive host,
// including this one — so the link would die before the customer ever
// saw it, the exact cross-host failure this controller exists to fix.
//
// Forced to the portal's own configured root instead, derived from
// config('app.url') rather than hardcoded, and restored immediately
// after: nothing else generated later in this request should inherit
// the override.
$portalRoot = rtrim((string) config('app.url'), '/');
URL::forceRootUrl($portalRoot);
try {
$signed = URL::temporarySignedRoute(
'impersonate.enter',
now()->addSeconds(self::WINDOW),
// 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.
['customer' => $customer->uuid, 'operator' => $operator->uuid],
);
} finally {
URL::forceRootUrl(null);
}
return redirect()->away($signed);
}
/** The portal end of the handover. Signed, and good exactly once. */

View File

@ -77,3 +77,39 @@ it('leaves the operator session untouched, so leaving returns to the console', f
expect(Auth::guard('web')->check())->toBeFalse()
->and(Auth::guard('operator')->check())->toBeTrue();
});
it('signs the link against the portal host, not the console host it was issued from', function () {
// Exclusive mode is where this bites: the console has a hostname to
// itself, and RestrictAdminHost 404s any non-console route reached
// through it. impersonate.enter carries no domain of its own, so a link
// built and signed against the console's root would die the instant it
// was followed — precisely the cross-host failure this task exists to
// fix. A test that only checks `signature=` is present does not catch
// this: on dev it passes anyway, because exclusive mode is off there.
config()->set('admin_access.hosts', ['admin.example.test']);
config()->set('admin_access.exclusive', true);
config()->set('app.url', 'https://app.example.test');
$operator = Operator::factory()->role('Owner')->create();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
// Issued exactly as a real operator's click would arrive: on the
// console's own exclusive host. (Routes are compiled once at boot, under
// this suite's default non-exclusive ADMIN_HOSTS="" — so the path still
// carries the fallback /admin prefix; only RestrictAdminHost's own checks
// read admin_access.* live, per request, which is what is under test.)
$response = $this->actingAs($operator, 'operator')
->post('https://admin.example.test/admin/impersonate/'.$customer->uuid);
$location = $response->headers->get('Location');
// The link has to carry the PORTAL's host, not the console's.
expect(parse_url($location, PHP_URL_HOST))->toBe('app.example.test');
// And it must still genuinely work when followed: valid signature, signs
// the customer in.
$this->get($location)->assertRedirect(route('dashboard'));
expect(Auth::guard('web')->id())->toBe($user->id);
});