[2.x] Added Browser Session Feature (#340)

This commit is contained in:
Rasel Islam Rafi 2024-11-05 21:19:55 +06:00 committed by GitHub
parent 0f810f4077
commit 2c49e2712c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 503 additions and 1 deletions

168
app/Helpers/Agent.php Normal file
View File

@ -0,0 +1,168 @@
<?php
namespace App\Helpers;
use Closure;
use Detection\MobileDetect;
class Agent extends MobileDetect
{
/**
* List of additional operating systems.
*
* @var array<string, string>
*/
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<string, string>
*/
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<string, mixed>
*/
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<string, string>
*/
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;
}
}

View File

@ -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],
];
}

View File

@ -0,0 +1,168 @@
<?php
namespace App\Web\Pages\Settings\Profile\Widgets;
use App\Helpers\Agent;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Infolists\Components\Actions\Action;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Concerns\InteractsWithInfolists;
use Filament\Infolists\Contracts\HasInfolists;
use Filament\Infolists\Infolist;
use Filament\Notifications\Notification;
use Filament\Widgets\Widget;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class BrowserSession extends Widget implements HasForms, HasInfolists
{
use InteractsWithForms;
use InteractsWithInfolists;
protected static bool $isLazy = false;
protected static string $view = 'components.infolist';
public function infolist(Infolist $infolist): Infolist
{
return $infolist
->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();
}
}

View File

@ -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"
},

66
composer.lock generated
View File

@ -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",

View File

@ -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');
}

View File

@ -0,0 +1,98 @@
<?php
namespace Tests\Unit\Helpers;
use App\Helpers\Agent;
use Tests\TestCase;
class AgentTest extends TestCase
{
private $operatingSystems = [
'Mozilla/5.0 (Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko' => '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);
}
}
}