Hand impersonation over by signed link, not by a cookie that cannot cross hosts
parent
73dec7f685
commit
26c84e3695
|
|
@ -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');
|
||||||
|
|
||||||
if ($operatorId !== null && ($operator = Operator::query()->find($operatorId)) !== null) {
|
|
||||||
// Ends the customer session rather than leaving it dangling
|
|
||||||
// alongside the restored operator one.
|
|
||||||
Auth::guard('web')->logout();
|
Auth::guard('web')->logout();
|
||||||
Auth::guard('operator')->login($operator);
|
|
||||||
|
|
||||||
return redirect()->route('admin.overview');
|
return redirect()->to(\App\Support\AdminArea::home());
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->route('dashboard');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 () {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue