From 8939e38a3c245bc57e8afb76f8677de2e77337bb Mon Sep 17 00:00:00 2001 From: Lewis Larsen Date: Sat, 17 Aug 2024 20:09:20 +0100 Subject: [PATCH] fix: Session manager tweaks --- composer.json | 2 +- composer.lock | 108 ++------ .../profile/session-manager.blade.php | 262 +++++++++++++----- tests/Feature/Profile/SessionTest.php | 62 ++--- 4 files changed, 246 insertions(+), 188 deletions(-) diff --git a/composer.json b/composer.json index a33a34b9..3000b8d1 100644 --- a/composer.json +++ b/composer.json @@ -7,9 +7,9 @@ "require": { "php": "^8.2", "blade-ui-kit/blade-heroicons": "^2.3", - "cjmellor/browser-sessions": "^1.1", "danharrin/livewire-rate-limiting": "^1.3", "diglactic/laravel-breadcrumbs": "^9.0", + "jenssegers/agent": "^2.6", "laragear/two-factor": "^2.0", "laravel/framework": "^11.9", "laravel/horizon": "^5.24", diff --git a/composer.lock b/composer.lock index 8131d4ec..d92eb890 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dab54331f1886f8845c0cbbf615d8969", + "content-hash": "d0ddff4c47e9bf0aa570e061334d831b", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.319.4", + "version": "3.320.2", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "b39a56786bef9e922c8bdd0e47c73ba828cc512e" + "reference": "dbae075b861316237d63418715f8bf4bfdd9d33d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b39a56786bef9e922c8bdd0e47c73ba828cc512e", - "reference": "b39a56786bef9e922c8bdd0e47c73ba828cc512e", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/dbae075b861316237d63418715f8bf4bfdd9d33d", + "reference": "dbae075b861316237d63418715f8bf4bfdd9d33d", "shasum": "" }, "require": { @@ -154,9 +154,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.319.4" + "source": "https://github.com/aws/aws-sdk-php/tree/3.320.2" }, - "time": "2024-08-13T18:04:50+00:00" + "time": "2024-08-16T18:06:17+00:00" }, { "name": "bacon/bacon-qr-code", @@ -491,75 +491,6 @@ ], "time": "2024-02-09T16:56:22+00:00" }, - { - "name": "cjmellor/browser-sessions", - "version": "v1.1.0", - "source": { - "type": "git", - "url": "https://github.com/cjmellor/browser-sessions.git", - "reference": "28d4cc4ab9501f17504a39fe990197d6ae303e9c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/cjmellor/browser-sessions/zipball/28d4cc4ab9501f17504a39fe990197d6ae303e9c", - "reference": "28d4cc4ab9501f17504a39fe990197d6ae303e9c", - "shasum": "" - }, - "require": { - "illuminate/support": "^10.0|^11.0", - "jenssegers/agent": "^2.6", - "php": "^8.2", - "spatie/laravel-package-tools": "^1.14" - }, - "require-dev": { - "laravel/pint": "^1.0", - "nunomaduro/collision": "^7.0|^8.0", - "orchestra/testbench": "^8.0|^9.0", - "pestphp/pest": "^2.0", - "pestphp/pest-plugin-arch": "^2.0", - "pestphp/pest-plugin-laravel": "^2.0", - "phpunit/phpunit": "^10.0" - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Cjmellor\\BrowserSessions\\BrowserSessionsServiceProvider" - ], - "aliases": { - "BrowserSessions": "Cjmellor\\BrowserSessions\\Facades\\BrowserSessions" - } - }, - "minimum-stability": "stable", - "prefer-stable": true - }, - "autoload": { - "psr-4": { - "Cjmellor\\BrowserSessions\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Chris Mellor", - "email": "cmellor@gmail.com" - } - ], - "description": "A Laravel package to enable users to manage and monitor their active browser sessions. Allows users to view devices where they are logged in and provides options to terminate unrecognized or all sessions, enhancing account security", - "homepage": "https://github.com/cjmellor/browser-sessions", - "keywords": [ - "browser-sessions", - "laravel" - ], - "support": { - "issues": "https://github.com/cjmellor/browser-sessions/issues", - "source": "https://github.com/cjmellor/browser-sessions/tree/v1.1.0" - }, - "time": "2024-03-24T00:16:50+00:00" - }, { "name": "clue/redis-protocol", "version": "v0.3.2", @@ -3191,16 +3122,16 @@ }, { "name": "league/commonmark", - "version": "2.5.2", + "version": "2.5.3", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "df09d5b6a4188f8f3c3ab2e43a109076a5eeb767" + "reference": "b650144166dfa7703e62a22e493b853b58d874b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/df09d5b6a4188f8f3c3ab2e43a109076a5eeb767", - "reference": "df09d5b6a4188f8f3c3ab2e43a109076a5eeb767", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/b650144166dfa7703e62a22e493b853b58d874b0", + "reference": "b650144166dfa7703e62a22e493b853b58d874b0", "shasum": "" }, "require": { @@ -3213,8 +3144,8 @@ }, "require-dev": { "cebe/markdown": "^1.0", - "commonmark/cmark": "0.31.0", - "commonmark/commonmark.js": "0.31.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", "composer/package-versions-deprecated": "^1.8", "embed/embed": "^4.4", "erusev/parsedown": "^1.0", @@ -3293,7 +3224,7 @@ "type": "tidelift" } ], - "time": "2024-08-14T10:56:57+00:00" + "time": "2024-08-16T11:46:16+00:00" }, { "name": "league/config", @@ -12199,12 +12130,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "a143e7459e3961149eb6a8eecc98dfa19799d02a" + "reference": "251a4f1fefcc6e6cc90d50514fee6b6e3745cb3e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/a143e7459e3961149eb6a8eecc98dfa19799d02a", - "reference": "a143e7459e3961149eb6a8eecc98dfa19799d02a", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/251a4f1fefcc6e6cc90d50514fee6b6e3745cb3e", + "reference": "251a4f1fefcc6e6cc90d50514fee6b6e3745cb3e", "shasum": "" }, "conflict": { @@ -12370,7 +12301,7 @@ "ezsystems/ezplatform-graphql": ">=1.0.0.0-RC1-dev,<1.0.13|>=2.0.0.0-beta1,<2.3.12", "ezsystems/ezplatform-kernel": "<1.2.5.1-dev|>=1.3,<1.3.35", "ezsystems/ezplatform-rest": ">=1.2,<=1.2.2|>=1.3,<1.3.8", - "ezsystems/ezplatform-richtext": ">=2.3,<2.3.7.1-dev", + "ezsystems/ezplatform-richtext": ">=2.3,<2.3.7.1-dev|>=3.3,<3.3.40", "ezsystems/ezplatform-solr-search-engine": ">=1.7,<1.7.12|>=2,<2.0.2|>=3.3,<3.3.15", "ezsystems/ezplatform-user": ">=1,<1.0.1", "ezsystems/ezpublish-kernel": "<6.13.8.2-dev|>=7,<7.5.31", @@ -12451,6 +12382,7 @@ "hyn/multi-tenant": ">=5.6,<5.7.2", "ibexa/admin-ui": ">=4.2,<4.2.3|>=4.6.0.0-beta1,<4.6.9", "ibexa/core": ">=4,<4.0.7|>=4.1,<4.1.4|>=4.2,<4.2.3|>=4.5,<4.5.6|>=4.6,<4.6.2", + "ibexa/fieldtype-richtext": ">=4.6,<4.6.10", "ibexa/graphql": ">=2.5,<2.5.31|>=3.3,<3.3.28|>=4.2,<4.2.3", "ibexa/post-install": "<=1.0.4", "ibexa/solr": ">=4.5,<4.5.4", @@ -13007,7 +12939,7 @@ "type": "tidelift" } ], - "time": "2024-08-12T19:04:53+00:00" + "time": "2024-08-14T19:05:08+00:00" }, { "name": "sebastian/cli-parser", diff --git a/resources/views/livewire/profile/session-manager.blade.php b/resources/views/livewire/profile/session-manager.blade.php index 44632ad7..1b15234c 100644 --- a/resources/views/livewire/profile/session-manager.blade.php +++ b/resources/views/livewire/profile/session-manager.blade.php @@ -1,11 +1,15 @@ Active user sessions. */ public Collection $sessions; - /** - * User's password for authentication when logging out other sessions. - * - * @var string - */ + /** @var string User's password for authentication when logging out other sessions. */ #[Rule('required|string')] public string $password = ''; - /** - * Initialize the component and load active sessions. - * - * @return void - */ + /** @var object|null Currently selected session for detailed view. */ + public ?object $selectedSession = null; + public function mount(): void { $this->loadSessions(); } - /** - * Load active sessions for the authenticated user. - * - * @return void - */ public function loadSessions(): void { if (!Auth::check()) { return; } - $this->sessions = BrowserSessions::sessions(); + $this->sessions = $this->getSessions(); } - /** - * Log out from all other browser sessions. - * - * @return void - */ public function logoutOtherBrowserSessions(): void { $this->validate([ 'password' => ['required', 'string', 'current_password'], ]); - BrowserSessions::logoutOtherBrowserSessions(); + $this->doLogoutOtherBrowserSessions(); $this->loadSessions(); $this->password = ''; $this->dispatch('close-modal', 'confirm-logout-other-browser-sessions'); - Toaster::success('All other browser sessions have been successfully terminated.'); + Toaster::success(__('All other browser sessions have been successfully terminated.')); } - /** - * Terminate a specific session. - * - * @param string $sessionId - * @return void - */ public function logoutSession(string $sessionId): void { if (!Auth::check()) { return; } - DB::table(config('session.table')) + DB::table(Config::get('session.table', 'sessions')) ->where('id', $sessionId) ->where('user_id', Auth::id()) ->delete(); $this->loadSessions(); - Toaster::success('The selected session has been successfully terminated.'); + $this->selectedSession = null; + $this->dispatch('close-modal', 'session-details'); + Toaster::success(__('The selected session has been successfully terminated.')); + } + + public function showSessionDetails(string $sessionId): void + { + $this->selectedSession = $this->sessions->firstWhere('id', $sessionId); + $this->dispatch('open-modal', 'session-details'); } - /** - * Check if the current session driver is set to database. - * - * @return bool - */ #[Computed] public function isDatabaseDriver(): bool { - return Config::get('session.driver') === 'database'; + return Config::get('session.driver') === 'database' && request()->hasSession(); } - /** - * Get the user's last activity in a human-readable format. - * - * @return string - */ #[Computed] public function userLastActivity(): string { - return BrowserSessions::getUserLastActivity(human: true); + return $this->getUserLastActivity(true); } -}; ?> + + protected function getSessions(): Collection + { + if (!$this->isDatabaseDriver) { + return collect(); + } + + return collect( + DB::connection(Config::get('session.connection')) + ->table(Config::get('session.table', 'sessions')) + ->where('user_id', Auth::id()) + ->latest('last_activity') + ->get() + )->map(function ($session) { + $agent = $this->createAgent($session); + $location = $this->getLocationFromIp($session->ip_address); + + return (object) [ + 'id' => $session->id, + 'device' => [ + 'browser' => $agent->browser(), + 'desktop' => $agent->isDesktop(), + 'mobile' => $agent->isMobile(), + 'tablet' => $agent->isTablet(), + 'platform' => $agent->platform(), + ], + 'ip_address' => $session->ip_address, + 'is_current_device' => $session->id === request()->session()->getId(), + 'last_active' => Carbon::createFromTimestamp($session->last_activity)->diffForHumans(), + 'location' => $location, + ]; + }); + } + + protected function createAgent(object $session): Agent + { + return tap( + new Agent(), + fn (Agent $agent) => $agent->setUserAgent($session->user_agent) + ); + } + + protected function getLocationFromIp(string $ip): array + { + try { + $response = Http::get("http://ip-api.com/json/{$ip}"); + + if ($response->successful()) { + $data = $response->json(); + return [ + 'city' => $data['city'] ?? 'Unknown', + 'country' => $data['country'] ?? 'Unknown', + 'latitude' => $data['lat'] ?? 0, + 'longitude' => $data['lon'] ?? 0, + ]; + } + } catch (\Exception $e) { + // Log the error if needed + } + + return [ + 'city' => 'Unknown', + 'country' => 'Unknown', + 'latitude' => 0, + 'longitude' => 0, + ]; + } + + protected function doLogoutOtherBrowserSessions(): void + { + $user = Auth::user(); + if (!$user) { + throw ValidationException::withMessages([ + 'user' => [__('User not found.')], + ]); + } + + if (!Hash::check($this->password, $user->password)) { + throw ValidationException::withMessages([ + 'password' => [__('This password does not match our records.')], + ]); + } + + Auth::guard()->logoutOtherDevices($this->password); + + $this->deleteOtherSessionRecords(); + } + + protected function deleteOtherSessionRecords(): void + { + if (!$this->isDatabaseDriver) { + return; + } + + DB::connection(Config::get('session.connection')) + ->table(Config::get('session.table', 'sessions')) + ->where('user_id', Auth::id()) + ->where('id', '!=', request()->session()->getId()) + ->delete(); + } + + protected function getUserLastActivity(bool $human = false): Carbon|string + { + $lastActivity = DB::connection(Config::get('session.connection')) + ->table(Config::get('session.table', 'sessions')) + ->where('user_id', Auth::id()) + ->latest('last_activity') + ->first(); + + if (!$lastActivity) { + return $human ? __('Never') : Carbon::now(); + } + + $timestamp = Carbon::createFromTimestamp($lastActivity->last_activity); + return $human ? $timestamp->diffForHumans() : $timestamp; + } +} +?>
@@ -165,11 +258,13 @@ public function userLastActivity(): string
- @if ($session->is_current_device) - - {{ __('Current Device') }} - - @else + + {{ __('See More') }} + + @if (!$session->is_current_device) {{ __('Terminate') }} + @else + + {{ __('Current Device') }} + @endif
- @if (!$session->is_current_device) -
-

- {{ __('Device type') }}: {{ $session->device['desktop'] ? 'Desktop' : ($session->device['mobile'] ? 'Mobile' : ($session->device['tablet'] ? 'Tablet' : 'Unknown')) }} -

-
- @endif @endforeach @@ -226,6 +318,7 @@ class="w-full sm:w-auto justify-center" + + + + {{ __('Session Details') }} + + + {{ __('Detailed information about the selected session.') }} + + + heroicon-o-information-circle + + + @if ($selectedSession) +
+

+ {{ $selectedSession->device['browser'] }} on {{ $selectedSession->device['platform'] }} +

+

+ {{ __('IP Address') }}: {{ $selectedSession->ip_address }} +

+

+ {{ __('Last Active') }}: {{ $selectedSession->last_active }} +

+

+ {{ __('Location') }}: {{ $selectedSession->location['city'] }}, {{ $selectedSession->location['country'] }} +

+ + @if (!$selectedSession->is_current_device) +
+ + {{ __('Terminate This Session') }} + +
+ @endif +
+ @endif +
@endif diff --git a/tests/Feature/Profile/SessionTest.php b/tests/Feature/Profile/SessionTest.php index 7f05fb66..a46e7733 100644 --- a/tests/Feature/Profile/SessionTest.php +++ b/tests/Feature/Profile/SessionTest.php @@ -3,53 +3,45 @@ declare(strict_types=1); use App\Models\User; -use Cjmellor\BrowserSessions\Facades\BrowserSessions; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Hash; use Livewire\Volt\Volt; beforeEach(function (): void { - $this->user = User::factory()->create(); + $this->user = User::factory()->create(['password' => Hash::make('password')]); $this->actingAs($this->user); + Config::set('session.driver', 'database'); }); -describe('Session Manager Component', function (): void { - test('the component can be rendered', function (): void { - Volt::test('profile.session-manager') - ->assertOk(); - }); - - test('the page can be visited by authenticated users', function (): void { - $this->get(route('profile.sessions')) - ->assertOk() - ->assertSeeLivewire('profile.session-manager'); - }); - - test('the page cannot be visited by guests', function (): void { - Auth::logout(); - $this->get(route('profile.sessions')) - ->assertRedirect('login'); - $this->assertGuest(); - }); +test('the component can be rendered', function (): void { + Volt::test('profile.session-manager') + ->assertOk(); }); -describe('Session Loading', function (): void { - test('it loads sessions for authenticated user', function (): void { - $mockSessions = collect([ - (object) ['id' => '1', 'ip_address' => '127.0.0.1', 'is_current_device' => true, 'last_active' => now(), 'device' => ['browser' => 'Chrome', 'platform' => 'Windows', 'desktop' => true]], - (object) ['id' => '2', 'ip_address' => '192.168.1.1', 'is_current_device' => false, 'last_active' => now()->subHour(), 'device' => ['browser' => 'Firefox', 'platform' => 'MacOS', 'desktop' => true]], - ]); +test('the page can be visited by authenticated users', function (): void { + $this->get(route('profile.sessions')) + ->assertOk() + ->assertSeeLivewire('profile.session-manager'); +}); - BrowserSessions::shouldReceive('sessions')->once()->andReturn($mockSessions); +test('the page cannot be visited by guests', function (): void { + Auth::logout(); + $this->get(route('profile.sessions')) + ->assertRedirect('login'); + $this->assertGuest(); +}); - Volt::test('profile.session-manager') - ->assertSet('sessions', $mockSessions); - }); +test('it shows a warning when session driver is not database', function (): void { + Config::set('session.driver', 'file'); - test('it shows a warning when session driver is not database', function (): void { - Config::set('session.driver', 'file'); + Volt::test('profile.session-manager') + ->assertSee('The session driver is not configured to use the database'); +}); - Volt::test('profile.session-manager') - ->assertSee('The session driver is not configured to use the database'); - }); +test('it requires correct password to logout other browser sessions', function (): void { + Volt::test('profile.session-manager') + ->set('password', 'wrong_password') + ->call('logoutOtherBrowserSessions') + ->assertHasErrors(['password']); });