108 lines
4.5 KiB
PHP
108 lines
4.5 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\Customer;
|
|
use App\Models\Operator;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\URL;
|
|
|
|
/**
|
|
* Looking at a customer's portal as that customer.
|
|
*
|
|
* 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
|
|
{
|
|
/** Sixty seconds is a handover, not a credential. */
|
|
private const WINDOW = 60;
|
|
|
|
public function start(Customer $customer): RedirectResponse
|
|
{
|
|
$this->authorize('customers.impersonate');
|
|
|
|
$operator = Auth::guard('operator')->user();
|
|
|
|
// 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. */
|
|
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');
|
|
}
|
|
|
|
/** Back out of the customer's portal. The operator session was never touched. */
|
|
public function leave(): RedirectResponse
|
|
{
|
|
session()->forget('impersonator_id');
|
|
Auth::guard('web')->logout();
|
|
|
|
// By NAME, not AdminArea::home(): this route is reached on the
|
|
// PORTAL's host, and home() is a bare path ('/' in exclusive mode) —
|
|
// resolved against whatever host the request is already on, which is
|
|
// the portal's, not the console's. admin.overview is domain-bound to
|
|
// the console in exclusive mode, so route() generates the correct
|
|
// cross-host, absolute URL back to it; in shared mode it resolves
|
|
// against the current host same as home() would, so nothing changes
|
|
// there.
|
|
return redirect()->route('admin.overview');
|
|
}
|
|
}
|