From 055c6228f448e5eb04d348578e893ae67475699b Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 28 Jul 2026 15:02:00 +0200 Subject: [PATCH] Stop a suspended customer's sign-out from killing a shared operator session EnsureCustomerActive used session()->invalidate() to sign a suspended or closed customer out. In shared-host mode the console and the portal keep their login key in one session, so invalidate() (flush() + migrate(true)) silently signed out an operator elsewhere in that same browser too. Same fix as the console's own /logout route: regenerate() gets a fresh session id without flushing attributes belonging to another guard. --- app/Http/Middleware/EnsureCustomerActive.php | 15 ++++++- tests/Feature/EnsureCustomerActiveTest.php | 44 ++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/EnsureCustomerActiveTest.php diff --git a/app/Http/Middleware/EnsureCustomerActive.php b/app/Http/Middleware/EnsureCustomerActive.php index 47604c8..48494e4 100644 --- a/app/Http/Middleware/EnsureCustomerActive.php +++ b/app/Http/Middleware/EnsureCustomerActive.php @@ -27,7 +27,20 @@ class EnsureCustomerActive if ($customer !== null && ($customer->status === 'suspended' || $customer->status === 'closed' || $customer->closed_at !== null)) { Auth::logout(); - $request->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 'web' guard's, so it would silently sign + // an operator elsewhere in that same browser out of the console + // too. regenerate() issues a new session id (no fixation risk) + // without touching the other attributes, so the 'operator' + // guard's own login key — untouched by the Auth::logout() call + // above, which only logs out the default ('web') guard — + // survives untouched. Same fix as routes/admin.php's own + // /logout route, mirrored the other way round. + $request->session()->regenerate(); $request->session()->regenerateToken(); $key = $customer->closed_at !== null || $customer->status === 'closed' ? 'auth.account_closed' : 'auth.account_suspended'; diff --git a/tests/Feature/EnsureCustomerActiveTest.php b/tests/Feature/EnsureCustomerActiveTest.php new file mode 100644 index 0000000..9d02e38 --- /dev/null +++ b/tests/Feature/EnsureCustomerActiveTest.php @@ -0,0 +1,44 @@ +invalidate() to + * sign a suspended or closed customer out — flush() + migrate(true) — which + * discarded every attribute in the session, not only the 'web' guard's, so it + * silently signed an operator elsewhere in that same browser out of the + * console too. + */ +it("suspending a portal customer does not sign an operator out of a console session sharing the same browser", function () { + $operator = Operator::factory()->role('Owner')->create(); + $user = User::factory()->create(); + Customer::factory()->create(['email' => $user->email, 'user_id' => $user->id, 'status' => 'suspended']); + + // Auth::guard(...)->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. + Auth::guard('operator')->login($operator); + Auth::guard('web')->login($user); + + $this->get(route('dashboard'))->assertRedirect(route('login')); + + // Auth::guard('operator') 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('web')->check())->toBeFalse() + ->and(Auth::guard('operator')->check())->toBeTrue() + ->and(Auth::guard('operator')->id())->toBe($operator->id); +});