From 16867f7ae5e4d13eb6f7ebc0ab70b983f0002b77 Mon Sep 17 00:00:00 2001 From: Denis Skripchenko Date: Wed, 14 Oct 2026 15:45:33 +0300 Subject: [PATCH] fix(auth): a sign-out stays signed out when a running request saves the session back A request in flight with the session at the sign-out saved it back under the old id and returned the old cookie: the next page load was signed in again (seen on the stand once the sign-out at the provider reloads the page). The sign-out now remembers the ended id in the cache for the session lifetime; AdminAuth (before the public actions) and the BootstrapBuilder (the shell) sign such a request out and give it a fresh session. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 15 +++++ src/Auth/Controllers/AuthController.php | 2 + src/Auth/EndedSessions.php | 71 ++++++++++++++++++++++ src/Http/Middleware/AdminAuth.php | 7 ++- src/Support/BootstrapBuilder.php | 2 + tests/Feature/EndedSessionTest.php | 78 +++++++++++++++++++++++++ 6 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 src/Auth/EndedSessions.php create mode 100644 tests/Feature/EndedSessionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b28b54..f499faba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to `dskripchenko/laravel-admin` will be documented in this f The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Fixed + +- **A sign-out could be undone by a request still running.** The sign-out + invalidates the session, but a request already in flight with it — the + shell's manifest, a status poll — saved it back under the old id when it + finished and returned the old cookie, so the next page load was signed in + again. Unnoticed while the SPA stayed on its login page after a sign-out; + visible since 1.51.0, when a sign-out at an identity provider reloads the + page. A sign-out now remembers the ended session id for the session's + lifetime (`Auth\EndedSessions`, in the cache), and a request carrying it is + signed out and gets a fresh session — in the API, the bootstrap and the + shell alike. A cache that is down only skips the check. + ## 1.51.0 ### Added diff --git a/src/Auth/Controllers/AuthController.php b/src/Auth/Controllers/AuthController.php index add17def..c14e9cbb 100644 --- a/src/Auth/Controllers/AuthController.php +++ b/src/Auth/Controllers/AuthController.php @@ -318,6 +318,8 @@ public function logout(Request $request): JsonResponse Auth::guard($guard)->logout(); if ($request->hasSession()) { + // A request still running with this session must not save it back to life. + \Dskripchenko\LaravelAdmin\Auth\EndedSessions::end($request->session()->getId()); $request->session()->invalidate(); $request->session()->regenerateToken(); } diff --git a/src/Auth/EndedSessions.php b/src/Auth/EndedSessions.php new file mode 100644 index 00000000..31d4d0a6 --- /dev/null +++ b/src/Auth/EndedSessions.php @@ -0,0 +1,71 @@ +addMinutes(max(1, (int) config('session.lifetime', 120)))); + } catch (\Throwable $e) { + report($e); + } + } + + /** + * Signs the request out of `$guard` when its session was ended. + * + * @return bool whether it was + */ + public static function clearIfEnded(Request $request, string $guard): bool + { + if (! $request->hasSession()) { + return false; + } + try { + if (! Cache::has(self::key($request->session()->getId()))) { + return false; + } + } catch (\Throwable $e) { + report($e); + + return false; + } + + Auth::guard($guard)->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return true; + } + + private static function key(string $sessionId): string + { + return self::PREFIX.hash('sha256', $sessionId); + } +} diff --git a/src/Http/Middleware/AdminAuth.php b/src/Http/Middleware/AdminAuth.php index a9e07581..79cea6b4 100644 --- a/src/Http/Middleware/AdminAuth.php +++ b/src/Http/Middleware/AdminAuth.php @@ -31,6 +31,11 @@ final class AdminAuth { public function handle(Request $request, Closure $next): SymfonyResponse { + // Before the public actions too: the bootstrap of a signed-out + // session must not see the user again. + $guard = \Dskripchenko\LaravelAdmin\Panel\Panels::currentGuard(); + \Dskripchenko\LaravelAdmin\Auth\EndedSessions::clearIfEnded($request, $guard); + if ($this->isExcludedForCurrentAction()) { /** @var SymfonyResponse $response */ $response = $next($request); @@ -38,8 +43,6 @@ public function handle(Request $request, Closure $next): SymfonyResponse return $response; } - $guard = \Dskripchenko\LaravelAdmin\Panel\Panels::currentGuard(); - if (! Auth::guard($guard)->check()) { return response()->json([ 'success' => false, diff --git a/src/Support/BootstrapBuilder.php b/src/Support/BootstrapBuilder.php index 7003b4f2..2b7769f4 100644 --- a/src/Support/BootstrapBuilder.php +++ b/src/Support/BootstrapBuilder.php @@ -44,6 +44,8 @@ public function build(?Request $request = null): array $request ??= request(); $panel = \Dskripchenko\LaravelAdmin\Panel\Panels::current(); + // The shell's inline bootstrap does not pass AdminAuth. + \Dskripchenko\LaravelAdmin\Auth\EndedSessions::clearIfEnded($request, $panel->guard); $locale = $this->locales->resolve($request); $user = $this->serializeUser(); diff --git a/tests/Feature/EndedSessionTest.php b/tests/Feature/EndedSessionTest.php new file mode 100644 index 00000000..78d68844 --- /dev/null +++ b/tests/Feature/EndedSessionTest.php @@ -0,0 +1,78 @@ +driver()->getHandler()->write($id, serialize([ + '_token' => Str::random(40), + Auth::guard('admin')->getName() => $admin->getKey(), + ])); + + return $id; +} + +function sessionAdmin(): AdminUser +{ + return AdminUser::create(['name' => 'Out', 'email' => 'out-'.uniqid().'@example.com', 'password' => bin2hex(random_bytes(8))]); +} + +it('treats a session as signed in until it is ended', function (): void { + $admin = sessionAdmin(); + $id = storedSession($admin); + + $this->withCredentials()->withCookie(config('session.cookie'), $id) + ->getJson('/api/admin/system/bootstrap')->assertOk()->assertJsonPath('payload.user.id', $admin->getKey()); +}); + +it('gives an ended session that came back a guest bootstrap', function (): void { + $id = storedSession(sessionAdmin()); + EndedSessions::end($id); + + $this->withCredentials()->withCookie(config('session.cookie'), $id) + ->getJson('/api/admin/system/bootstrap')->assertOk()->assertJsonPath('payload.user', null); +}); + +it('turns an ended session that came back away from the API', function (): void { + $id = storedSession(sessionAdmin()); + EndedSessions::end($id); + + $this->withCredentials()->withCookie(config('session.cookie'), $id) + ->getJson('/api/admin/system/manifest')->assertUnauthorized(); + // And the stored session is gone, not just skipped. + expect(app('session')->driver()->getHandler()->read($id))->toBe(''); +}); + +it('remembers the session a sign-out ended', function (): void { + $this->actingAs(sessionAdmin(), 'admin'); + $this->startSession(); + $id = session()->getId(); + $this->withCredentials()->withCookie(config('session.cookie'), $id)->postJson('/api/admin/auth/logout')->assertOk(); + + expect(Cache::has('admin:ended-session:'.hash('sha256', $id)))->toBeTrue(); +}); + +it('goes on as before when the cache is down', function (): void { + $admin = sessionAdmin(); + $id = storedSession($admin); + Cache::shouldReceive('has')->andThrow(new RuntimeException('cache is down')); + Cache::shouldReceive('put')->andThrow(new RuntimeException('cache is down')); + + EndedSessions::end($id); + $this->withCredentials()->withCookie(config('session.cookie'), $id) + ->getJson('/api/admin/system/bootstrap')->assertOk()->assertJsonPath('payload.user.id', $admin->getKey()); +});