From 2b2bb439a5efe330cd9c7a9f2de660c367c034e0 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 28 Jul 2026 13:58:42 +0200 Subject: [PATCH] Stop the console logout from destroying a shared portal session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where the console and the portal share a host (shared/fallback mode — this VM's own mode), both guards keep their login key in the SAME session. session()->invalidate() is flush() + migrate(true) — it discards every attribute in the session, not only the operator's, so signing out of the console silently signed a customer out of the portal too if the same browser carried both. Replaced with session()->regenerate(): a new session id, so there is no fixation risk, while attributes belonging to any other guard survive untouched. --- routes/admin.php | 12 ++++++- tests/Feature/Admin/OperatorLogoutTest.php | 39 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Admin/OperatorLogoutTest.php diff --git a/routes/admin.php b/routes/admin.php index 0b7e014..ac1c484 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -44,7 +44,17 @@ Route::get('/settings', Admin\Settings::class)->name('settings'); // POST so Laravel's CSRF middleware protects it, like every other state change. Route::post('/logout', function () { Auth::guard('operator')->logout(); - session()->invalidate(); + + // NOT session()->invalidate(): where the console and the portal share a + // host (shared/fallback mode — this VM's own mode), both guards keep + // their login key in the SAME session. invalidate() is flush() + + // migrate(true) — it discards every attribute in the session, not just + // the operator's, so it would silently sign a customer out of the portal + // too if the same browser happened to carry both. regenerate() issues a + // new session id (no fixation risk) without touching the other + // attributes, so the 'web' guard's own login key — already removed for + // 'operator' by the logout() call above — survives untouched. + session()->regenerate(); session()->regenerateToken(); return redirect()->route('admin.login'); diff --git a/tests/Feature/Admin/OperatorLogoutTest.php b/tests/Feature/Admin/OperatorLogoutTest.php new file mode 100644 index 0000000..fa2348a --- /dev/null +++ b/tests/Feature/Admin/OperatorLogoutTest.php @@ -0,0 +1,39 @@ +login() rather than actingAs(): actingAs() only sets + // an in-memory user on the guard object, it never writes to the session + // — so it cannot tell session()->invalidate() and session()->regenerate() + // apart. login() does the same session write a real sign-in performs. + $operator = Operator::factory()->role('Owner')->create(); + $customer = User::factory()->create(); + + Auth::guard('web')->login($customer); + Auth::guard('operator')->login($operator); + + $this->post(route('admin.logout'))->assertRedirect(route('admin.login')); + + // Auth::guard('web') is still the SAME cached guard instance from the + // login() call above — its check() would say "authenticated" from its + // own in-memory $user property regardless of what happened to the + // session, which is exactly the blind spot that would make this + // assertion pass whether the fix is there or not. forgetGuards() drops + // that cache, forcing the next guard() call to rebuild from the CURRENT + // session data — the same thing a genuinely new request would do. + Auth::forgetGuards(); + + expect(Auth::guard('operator')->check())->toBeFalse() + ->and(Auth::guard('web')->check())->toBeTrue() + ->and(Auth::guard('web')->id())->toBe($customer->id); +});