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); // Stale data, not a normal outcome: a customer row sharing its email // with an operator should never have been possible to reach this far // (see Customer::ensureUser() and CreateNewUser, R21) — refuse rather // than hand the operator's own address a portal session. try { $target = Customer::where('uuid', $customer)->firstOrFail()->ensureUser(); } catch (IdentityCollisionException) { abort(409); } $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'); } }