-
Notifications
You must be signed in to change notification settings - Fork 222
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[2.x] Added Browser Session Feature (#340)
- Loading branch information
Showing
7 changed files
with
503 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
168 changes: 168 additions & 0 deletions
168
app/Web/Pages/Settings/Profile/Widgets/BrowserSession.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.