From 6b01034bdd57b544ea393dee5cffdb68e04ea09d Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Wed, 10 Jun 2026 13:28:35 +0200 Subject: [PATCH] add back destroy other browser sessions endpoint (jetstream migration) --- .../Web/OtherBrowserSessionsController.php | 54 +++++++++ routes/web.php | 3 + tests/Feature/BrowserSessionsTest.php | 25 ----- .../Web/OtherBrowserSessionsEndpointTest.php | 103 ++++++++++++++++++ 4 files changed, 160 insertions(+), 25 deletions(-) create mode 100644 app/Http/Controllers/Web/OtherBrowserSessionsController.php delete mode 100644 tests/Feature/BrowserSessionsTest.php create mode 100644 tests/Unit/Endpoint/Web/OtherBrowserSessionsEndpointTest.php diff --git a/app/Http/Controllers/Web/OtherBrowserSessionsController.php b/app/Http/Controllers/Web/OtherBrowserSessionsController.php new file mode 100644 index 00000000..0ad71ce7 --- /dev/null +++ b/app/Http/Controllers/Web/OtherBrowserSessionsController.php @@ -0,0 +1,54 @@ +string('password'); + + $confirmed = app(ConfirmPassword::class)($guard, $request->user(), $password); + + if (! $confirmed) { + throw ValidationException::withMessages([ + 'password' => __('The password is incorrect.'), + ]); + } + + $guard->logoutOtherDevices($password); + + $this->deleteOtherSessionRecords($request); + + return back(303); + } + + /** + * Delete the other browser session records from storage. + */ + protected function deleteOtherSessionRecords(Request $request): void + { + if (config('session.driver') !== 'database') { + return; + } + + DB::connection(config('session.connection')) + ->table(config('session.table', 'sessions')) + ->where('user_id', $request->user()->getAuthIdentifier()) + ->where('id', '!=', $request->session()->getId()) + ->delete(); + } +} diff --git a/routes/web.php b/routes/web.php index 51e9d9a7..01680408 100644 --- a/routes/web.php +++ b/routes/web.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Web\DashboardController; use App\Http\Controllers\Web\HomeController; use App\Http\Controllers\Web\OrganizationController; use App\Http\Controllers\Web\OrganizationInvitationController; +use App\Http\Controllers\Web\OtherBrowserSessionsController; use App\Http\Controllers\Web\UserController; use App\Http\Controllers\Web\UserProfileController; use App\Service\PermissionStore; @@ -102,6 +103,8 @@ Route::middleware([ return to_route('organizations.show', [$organizationId]); })->name('teams.show'); Route::get('/user/profile', [UserProfileController::class, 'show'])->name('profile.show'); + Route::delete('/user/other-browser-sessions', [OtherBrowserSessionsController::class, 'destroy']) + ->name('other-browser-sessions.destroy'); }); Route::get('/team-invitations/{invitation}', [OrganizationInvitationController::class, 'accept']) diff --git a/tests/Feature/BrowserSessionsTest.php b/tests/Feature/BrowserSessionsTest.php deleted file mode 100644 index b07c31d7..00000000 --- a/tests/Feature/BrowserSessionsTest.php +++ /dev/null @@ -1,25 +0,0 @@ -actingAs($user = User::factory()->create()); - - $response = $this->delete('/user/other-browser-sessions', [ - 'password' => 'password', - ]); - - $response->assertSessionHasNoErrors(); - } -} diff --git a/tests/Unit/Endpoint/Web/OtherBrowserSessionsEndpointTest.php b/tests/Unit/Endpoint/Web/OtherBrowserSessionsEndpointTest.php new file mode 100644 index 00000000..54e2cc80 --- /dev/null +++ b/tests/Unit/Endpoint/Web/OtherBrowserSessionsEndpointTest.php @@ -0,0 +1,103 @@ +create(); + $originalPasswordHash = $user->password; + $this->actingAs($user); + + // Act + $response = $this->delete('/user/other-browser-sessions', [ + 'password' => 'password', + ]); + + // Assert + $response->assertRedirect(); + $response->assertSessionHasNoErrors(); + // logoutOtherDevices re-hashes the password (same plaintext, new hash) to invalidate other sessions. + $this->assertNotSame($originalPasswordHash, $user->fresh()->password); + $this->assertTrue(Hash::check('password', $user->fresh()->password)); + } + + public function test_destroy_fails_with_an_incorrect_password(): void + { + // Arrange + $user = User::factory()->create(); + $originalPasswordHash = $user->password; + $this->actingAs($user); + + // Act + $response = $this->delete('/user/other-browser-sessions', [ + 'password' => 'wrong-password', + ]); + + // Assert + $response->assertSessionHasErrors('password'); + // No side effects when the password is incorrect: the password must not be re-hashed. + $this->assertSame($originalPasswordHash, $user->fresh()->password); + } + + public function test_destroy_requires_authentication(): void + { + // Act + $response = $this->delete('/user/other-browser-sessions', [ + 'password' => 'password', + ]); + + // Assert + $response->assertRedirect(route('login')); + } + + public function test_destroy_deletes_the_other_database_session_records_of_the_current_user(): void + { + // Arrange + config(['session.driver' => 'database']); + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + $this->actingAs($user); + + DB::table('sessions')->insert([ + [ + 'id' => 'other-session-of-current-user', + 'user_id' => $user->getKey(), + 'ip_address' => '192.0.2.10', + 'user_agent' => '', + 'payload' => '', + 'last_activity' => now()->subMinutes(5)->timestamp, + ], + [ + 'id' => 'session-of-another-user', + 'user_id' => $otherUser->getKey(), + 'ip_address' => '192.0.2.30', + 'user_agent' => '', + 'payload' => '', + 'last_activity' => now()->timestamp, + ], + ]); + + // Act + $response = $this->delete('/user/other-browser-sessions', [ + 'password' => 'password', + ]); + + // Assert + $response->assertSessionHasNoErrors(); + // The current user's other sessions are removed, while another user's session is untouched. + $this->assertDatabaseMissing('sessions', ['id' => 'other-session-of-current-user']); + $this->assertDatabaseHas('sessions', ['id' => 'session-of-another-user']); + } +}