Sign the impersonation link against the portal's own host, not the console's
parent
bdb9c0a04a
commit
88d9a66f44
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue