From 2c49e2712c4ebf81238b168db774e9bdc7abd3e9 Mon Sep 17 00:00:00 2001 From: Rasel Islam Rafi <31556372+rtraselbd@users.noreply.github.com> Date: Tue, 5 Nov 2024 21:19:55 +0600 Subject: [PATCH] [2.x] Added Browser Session Feature (#340) --- app/Helpers/Agent.php | 168 ++++++++++++++++++ app/Web/Pages/Settings/Profile/Index.php | 2 + .../Profile/Widgets/BrowserSession.php | 168 ++++++++++++++++++ composer.json | 1 + composer.lock | 66 ++++++- tests/Feature/ProfileTest.php | 1 + tests/Unit/Helpers/AgentTest.php | 98 ++++++++++ 7 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 app/Helpers/Agent.php create mode 100644 app/Web/Pages/Settings/Profile/Widgets/BrowserSession.php create mode 100644 tests/Unit/Helpers/AgentTest.php diff --git a/app/Helpers/Agent.php b/app/Helpers/Agent.php new file mode 100644 index 00000000..287961aa --- /dev/null +++ b/app/Helpers/Agent.php @@ -0,0 +1,168 @@ + + */ + protected static $additionalOperatingSystems = [ + 'Windows' => 'Windows', + 'Windows NT' => 'Windows NT', + 'OS X' => 'Mac OS X', + 'Debian' => 'Debian', + 'Ubuntu' => 'Ubuntu', + 'Macintosh' => 'PPC', + 'OpenBSD' => 'OpenBSD', + 'Linux' => 'Linux', + 'ChromeOS' => 'CrOS', + ]; + + /** + * List of additional browsers. + * + * @var array + */ + protected static $additionalBrowsers = [ + 'Opera Mini' => 'Opera Mini', + 'Opera' => 'Opera|OPR', + 'Edge' => 'Edge|Edg', + 'Coc Coc' => 'coc_coc_browser', + 'UCBrowser' => 'UCBrowser', + 'Vivaldi' => 'Vivaldi', + 'Chrome' => 'Chrome', + 'Firefox' => 'Firefox', + 'Safari' => 'Safari', + 'IE' => 'MSIE|IEMobile|MSIEMobile|Trident/[.0-9]+', + 'Netscape' => 'Netscape', + 'Mozilla' => 'Mozilla', + 'WeChat' => 'MicroMessenger', + ]; + + /** + * Key value store for resolved strings. + * + * @var array + */ + protected $store = []; + + /** + * Get the platform name from the User Agent. + * + * @return string|null + */ + public function platform() + { + return $this->retrieveUsingCacheOrResolve('paymently.platform', function () { + return $this->findDetectionRulesAgainstUserAgent( + $this->mergeRules(MobileDetect::getOperatingSystems(), static::$additionalOperatingSystems) + ); + }); + } + + /** + * Get the browser name from the User Agent. + * + * @return string|null + */ + public function browser() + { + return $this->retrieveUsingCacheOrResolve('paymently.browser', function () { + return $this->findDetectionRulesAgainstUserAgent( + $this->mergeRules(static::$additionalBrowsers, MobileDetect::getBrowsers()) + ); + }); + } + + /** + * Determine if the device is a desktop computer. + * + * @return bool + */ + public function isDesktop() + { + return $this->retrieveUsingCacheOrResolve('paymently.desktop', function () { + // Check specifically for cloudfront headers if the useragent === 'Amazon CloudFront' + if ( + $this->getUserAgent() === static::$cloudFrontUA + && $this->getHttpHeader('HTTP_CLOUDFRONT_IS_DESKTOP_VIEWER') === 'true' + ) { + return true; + } + + return ! $this->isMobile() && ! $this->isTablet(); + }); + } + + /** + * Match a detection rule and return the matched key. + * + * @return string|null + */ + protected function findDetectionRulesAgainstUserAgent(array $rules) + { + $userAgent = $this->getUserAgent(); + + foreach ($rules as $key => $regex) { + if (empty($regex)) { + continue; + } + + if ($this->match($regex, $userAgent)) { + return $key ?: reset($this->matchesArray); + } + } + + return null; + } + + /** + * Retrieve from the given key from the cache or resolve the value. + * + * @param \Closure():mixed $callback + * @return mixed + */ + protected function retrieveUsingCacheOrResolve(string $key, Closure $callback) + { + $cacheKey = $this->createCacheKey($key); + + if (! is_null($cacheItem = $this->store[$cacheKey] ?? null)) { + return $cacheItem; + } + + return tap(call_user_func($callback), function ($result) use ($cacheKey) { + $this->store[$cacheKey] = $result; + }); + } + + /** + * Merge multiple rules into one array. + * + * @param array $all + * @return array + */ + protected function mergeRules(...$all) + { + $merged = []; + + foreach ($all as $rules) { + foreach ($rules as $key => $value) { + if (empty($merged[$key])) { + $merged[$key] = $value; + } elseif (is_array($merged[$key])) { + $merged[$key][] = $value; + } else { + $merged[$key] .= '|'.$value; + } + } + } + + return $merged; + } +} diff --git a/app/Web/Pages/Settings/Profile/Index.php b/app/Web/Pages/Settings/Profile/Index.php index b1f4f89f..cd7038b1 100644 --- a/app/Web/Pages/Settings/Profile/Index.php +++ b/app/Web/Pages/Settings/Profile/Index.php @@ -3,6 +3,7 @@ namespace App\Web\Pages\Settings\Profile; use App\Web\Components\Page; +use App\Web\Pages\Settings\Profile\Widgets\BrowserSession; use App\Web\Pages\Settings\Profile\Widgets\ProfileInformation; use App\Web\Pages\Settings\Profile\Widgets\TwoFactor; use App\Web\Pages\Settings\Profile\Widgets\UpdatePassword; @@ -24,6 +25,7 @@ public function getWidgets(): array return [ [ProfileInformation::class], [UpdatePassword::class], + [BrowserSession::class], [TwoFactor::class], ]; } diff --git a/app/Web/Pages/Settings/Profile/Widgets/BrowserSession.php b/app/Web/Pages/Settings/Profile/Widgets/BrowserSession.php new file mode 100644 index 00000000..88bb3859 --- /dev/null +++ b/app/Web/Pages/Settings/Profile/Widgets/BrowserSession.php @@ -0,0 +1,168 @@ +schema([ + Section::make('Browser Sessions') + ->description('Manage and log out your active sessions on other browsers and devices.') + ->schema([ + TextEntry::make('session_content') + ->hiddenLabel() + ->state('If necessary, you may log out of all of your other browser sessions across all of your devices. Some of your recent sessions are listed below; however, this list may not be exhaustive. If you feel your account has been compromised, you should also update your password.'), + ...$this->getDynamicSchema(), + ]) + ->footerActions([ + Action::make('deleteBrowserSessions') + ->label('Log Out Other Browser Sessions') + ->requiresConfirmation() + ->modalHeading('Log Out Other Browser Sessions') + ->modalDescription('Please enter your password to confirm you would like to log out of your other browser sessions across all of your devices.') + ->modalSubmitActionLabel('Log Out Other Browser Sessions') + ->form([ + TextInput::make('password') + ->password() + ->revealable() + ->label('Password') + ->required(), + ]) + ->action(function (array $data) { + self::logoutOtherBrowserSessions($data['password']); + }) + ->modalWidth('2xl'), + ]), + ]); + } + + private function getDynamicSchema(): array + { + $sections = []; + + foreach ($this->getSessions() as $session) { + $sections[] = Section::make() + ->schema([ + TextEntry::make('device') + ->hiddenLabel() + ->icon($session->device['desktop'] ? 'heroicon-o-computer-desktop' : 'heroicon-o-device-phone-mobile') + ->state($session->device['platform'].' - '.$session->device['browser']), + TextEntry::make('browser') + ->hiddenLabel() + ->icon('heroicon-o-map-pin') + ->state($session->ip_address), + TextEntry::make('time') + ->hiddenLabel() + ->icon('heroicon-o-clock') + ->state($session->last_active), + TextEntry::make('is_current_device') + ->hiddenLabel() + ->visible(fn () => $session->is_current_device) + ->state('This device') + ->color('primary'), + ])->columns(4); + } + + return $sections; + } + + private function getSessions(): array + { + if (config(key: 'session.driver') !== 'database') { + return []; + } + + return collect( + value: DB::connection(config(key: 'session.connection'))->table(table: config(key: 'session.table', default: 'sessions')) + ->where(column: 'user_id', operator: Auth::user()->getAuthIdentifier()) + ->latest(column: 'last_activity') + ->get() + )->map(callback: function ($session): object { + $agent = $this->createAgent($session); + + return (object) [ + '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' => 'Last seen '.Carbon::createFromTimestamp($session->last_activity)->diffForHumans(), + ]; + })->toArray(); + } + + private function createAgent(mixed $session) + { + return tap( + value: new Agent, + callback: fn ($agent) => $agent->setUserAgent(userAgent: $session->user_agent) + ); + } + + private function logoutOtherBrowserSessions($password): void + { + if (! Hash::check($password, Auth::user()->password)) { + Notification::make() + ->danger() + ->title('The password you entered was incorrect. Please try again.') + ->send(); + + return; + } + + Auth::guard()->logoutOtherDevices($password); + + request()->session()->put([ + 'password_hash_'.Auth::getDefaultDriver() => Auth::user()->getAuthPassword(), + ]); + + $this->deleteOtherSessionRecords(); + + Notification::make() + ->success() + ->title('All other browser sessions have been logged out successfully.') + ->send(); + } + + private function deleteOtherSessionRecords(): void + { + if (config(key: 'session.driver') !== 'database') { + return; + } + + DB::connection(config(key: 'session.connection'))->table(table: config(key: 'session.table', default: 'sessions')) + ->where('user_id', Auth::user()->getAuthIdentifier()) + ->where('id', '!=', request()->session()->getId()) + ->delete(); + } +} diff --git a/composer.json b/composer.json index c33f9176..8648b33b 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "laravel/framework": "^11.0", "laravel/sanctum": "^4.0", "laravel/tinker": "^2.8", + "mobiledetect/mobiledetectlib": "^4.8", "phpseclib/phpseclib": "~3.0", "spatie/laravel-route-attributes": "^1.24" }, diff --git a/composer.lock b/composer.lock index f394b677..78757c97 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": "414770551e501c730f96ce36cc531c1a", + "content-hash": "be3e63b7efd71f649cbffb0d469ba7c1", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -3585,6 +3585,70 @@ }, "time": "2024-03-31T07:05:07+00:00" }, + { + "name": "mobiledetect/mobiledetectlib", + "version": "4.8.06", + "source": { + "type": "git", + "url": "https://github.com/serbanghita/Mobile-Detect.git", + "reference": "af088b54cecc13b3264edca7da93a89ba7aa2d9e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/af088b54cecc13b3264edca7da93a89ba7aa2d9e", + "reference": "af088b54cecc13b3264edca7da93a89ba7aa2d9e", + "shasum": "" + }, + "require": { + "php": ">=8.0", + "psr/simple-cache": "^2 || ^3" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.35.1", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.6", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Detection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Serban Ghita", + "email": "serbanghita@gmail.com", + "homepage": "http://mobiledetect.net", + "role": "Developer" + } + ], + "description": "Mobile_Detect is a lightweight PHP class for detecting mobile devices. It uses the User-Agent string combined with specific HTTP headers to detect the mobile environment.", + "homepage": "https://github.com/serbanghita/Mobile-Detect", + "keywords": [ + "detect mobile devices", + "mobile", + "mobile detect", + "mobile detector", + "php mobile detect" + ], + "support": { + "issues": "https://github.com/serbanghita/Mobile-Detect/issues", + "source": "https://github.com/serbanghita/Mobile-Detect/tree/4.8.06" + }, + "funding": [ + { + "url": "https://github.com/serbanghita", + "type": "github" + } + ], + "time": "2024-03-01T22:28:42+00:00" + }, { "name": "monolog/monolog", "version": "3.7.0", diff --git a/tests/Feature/ProfileTest.php b/tests/Feature/ProfileTest.php index 82342128..f1ede954 100644 --- a/tests/Feature/ProfileTest.php +++ b/tests/Feature/ProfileTest.php @@ -23,6 +23,7 @@ public function test_profile_page_is_displayed(): void ->assertSuccessful() ->assertSee('Profile Information') ->assertSee('Update Password') + ->assertSee('Browser Sessions') ->assertSee('Two Factor Authentication'); } diff --git a/tests/Unit/Helpers/AgentTest.php b/tests/Unit/Helpers/AgentTest.php new file mode 100644 index 00000000..58e0c7cb --- /dev/null +++ b/tests/Unit/Helpers/AgentTest.php @@ -0,0 +1,98 @@ + 'Windows', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/537.13+ (KHTML, like Gecko) Version/5.1.7 Safari/534.57.2' => 'OS X', + 'Mozilla/5.0 (iPad; CPU OS 5_1 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko ) Version/5.1 Mobile/9B176 Safari/7534.48.3' => 'iOS', + 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:24.0) Gecko/20100101 Firefox/24.0' => 'Ubuntu', + 'Mozilla/5.0 (BlackBerry; U; BlackBerry 9900; en) AppleWebKit/534.11+ (KHTML, like Gecko) Version/7.1.0.346 Mobile Safari/534.11+' => 'BlackBerryOS', + 'Mozilla/5.0 (Linux; U; Android 2.2; en-us; Nexus One Build/FRF91) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1' => 'AndroidOS', + 'Mozilla/5.0 (X11; CrOS x86_64 6680.78.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.102 Safari/537.36' => 'ChromeOS', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36' => 'Windows', + ]; + + private $browsers = [ + 'Mozilla/5.0 (Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko' => 'IE', + 'Mozilla/5.0 (iPad; CPU OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5355d Safari/8536.25' => 'Safari', + 'Mozilla/5.0 (Windows; U; Win 9x 4.90; SG; rv:1.9.2.4) Gecko/20101104 Netscape/9.1.0285' => 'Netscape', + 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:25.0) Gecko/20100101 Firefox/25.0' => 'Firefox', + 'Mozilla/5.0 (Windows NT 6.2; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/32.0.1667.0 Safari/537.36' => 'Chrome', + 'Mozilla/5.0 (Windows; U; Windows NT 6.1; rv:2.2) Gecko/20110201' => 'Mozilla', + 'Opera/9.80 (Windows NT 6.0) Presto/2.12.388 Version/12.14' => 'Opera', + 'Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.115 Safari/537.36 OPR/27.0.1689.76' => 'Opera', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12' => 'Edge', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5376e Safari/8536.25' => 'Safari', + 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36 Vivaldi/1.2.490.43' => 'Vivaldi', + 'Mozilla/5.0 (Linux; U; Android 4.0.4; en-US; LT28h Build/6.1.E.3.7) AppleWebKit/534.31 (KHTML, like Gecko) UCBrowser/9.2.2.323 U3/0.8.0 Mobile Safari/534.31' => 'UCBrowser', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36 Edge/15.15063' => 'Edge', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.29 Safari/537.36 Edg/79.0.309.18' => 'Edge', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) coc_coc_browser/86.0.180 Chrome/80.0.3987.180 Safari/537.36' => 'Coc Coc', + ]; + + private $mobileDevices = [ + 'Mozilla/5.0 (iPhone; U; ru; CPU iPhone OS 4_2_1 like Mac OS X; ru) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8C148a Safari/6533.18.5', + 'Mozilla/5.0 (iPad; CPU OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5355d Safari/8536.25', + 'Mozilla/5.0 (Linux; U; Android 2.3.4; fr-fr; HTC Desire Build/GRJ22) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1', + 'Mozilla/5.0 (BlackBerry; U; BlackBerry 9900; en) AppleWebKit/534.11+ (KHTML, like Gecko) Version/7.1.0.346 Mobile Safari/534.11+', + 'Mozilla/5.0 (Linux; U; Android 2.2; en-us; Nexus One Build/FRF91) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1', + 'Mozilla/5.0 (Linux; U; Android 4.0.3; en-us; ASUS Transformer Pad TF300T Build/IML74K) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Safari/534.30', + ]; + + private $desktops = [ + 'Mozilla/5.0 (Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko', + 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:24.0) Gecko/20100101 Firefox/24.0', + 'Mozilla/5.0 (Windows; U; Win 9x 4.90; SG; rv:1.9.2.4) Gecko/20101104 Netscape/9.1.0285', + 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:25.0) Gecko/20100101 Firefox/25.0', + 'Mozilla/5.0 (Windows NT 6.2; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/32.0.1667.0 Safari/537.36', + 'Mozilla/5.0 (Windows; U; Windows NT 6.1; rv:2.2) Gecko/20110201', + 'Opera/9.80 (Windows NT 6.0) Presto/2.12.388 Version/12.14', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/537.13+ (KHTML, like Gecko) Version/5.1.7 Safari/534.57.2', + ]; + + public function test_operating_systems() + { + $agent = new Agent; + + foreach ($this->operatingSystems as $ua => $platform) { + $agent->setUserAgent($ua); + $this->assertEquals($platform, $agent->platform(), $ua); + } + } + + public function test_browsers() + { + $agent = new Agent; + + foreach ($this->browsers as $ua => $browser) { + $agent->setUserAgent($ua); + $this->assertEquals($browser, $agent->browser(), $ua); + } + } + + public function test_desktop_devices() + { + $agent = new Agent; + + foreach ($this->desktops as $ua) { + $agent->setUserAgent($ua); + $this->assertTrue($agent->isDesktop(), $ua); + } + } + + public function test_mobile_devices() + { + $agent = new Agent; + + foreach ($this->mobileDevices as $ua) { + $agent->setUserAgent($ua); + $this->assertTrue($agent->isMobile(), $ua); + } + } +}