124 lines
4.6 KiB
PHP
124 lines
4.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Session;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* Database-session logic for the session-management UI (no Livewire here).
|
|
*
|
|
* The `database` session driver makes every login row in the `sessions` table
|
|
* enumerable per user, so we can list devices and revoke them. Revoking a user
|
|
* "everywhere" also rotates their `remember_token` — otherwise a stolen
|
|
* remember-me cookie would silently re-create a session.
|
|
*/
|
|
class SessionService
|
|
{
|
|
/**
|
|
* The user's sessions, newest activity first, decorated for the UI.
|
|
*
|
|
* Only id/ip/user-agent/last-activity are exposed — never the payload (it
|
|
* holds the serialized session, including other users' data is impossible
|
|
* here but the payload still contains CSRF tokens etc.).
|
|
*
|
|
* @return array<int, array{id: string, ip: ?string, userAgentShort: string, lastActivity: Carbon, isCurrent: bool}>
|
|
*/
|
|
public function forUser(User $user): array
|
|
{
|
|
$currentId = Session::getId();
|
|
|
|
return DB::table('sessions')
|
|
->where('user_id', $user->id)
|
|
->orderByDesc('last_activity')
|
|
->get(['id', 'ip_address', 'user_agent', 'last_activity'])
|
|
->map(fn ($row) => [
|
|
'id' => $row->id,
|
|
'ip' => $row->ip_address,
|
|
'userAgentShort' => $this->shortenUserAgent($row->user_agent),
|
|
'lastActivity' => Carbon::createFromTimestamp((int) $row->last_activity),
|
|
'isCurrent' => $row->id === $currentId,
|
|
])
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* Sign the user out of every device EXCEPT the current one. The current
|
|
* session stays valid (nothing rotated for self), so the operator keeps
|
|
* working uninterrupted.
|
|
*/
|
|
public function logoutOtherDevices(User $user): void
|
|
{
|
|
DB::table('sessions')
|
|
->where('user_id', $user->id)
|
|
->where('id', '!=', Session::getId())
|
|
->delete();
|
|
}
|
|
|
|
/**
|
|
* Kill ALL of this user's sessions and rotate their remember_token, so a
|
|
* stolen remember-me cookie can no longer resurrect a session. Used by the
|
|
* (separate) multi-user admin feature; not wired to a per-row button here.
|
|
*/
|
|
public function logoutUserEverywhere(User $user): void
|
|
{
|
|
DB::table('sessions')->where('user_id', $user->id)->delete();
|
|
|
|
$user->forceFill(['remember_token' => Str::random(60)])->save();
|
|
}
|
|
|
|
/**
|
|
* Global logout: truncate every session and rotate every user's
|
|
* remember_token. Destructive — logs out the current operator too. The
|
|
* caller (Livewire) is responsible for the R5 confirm, audit, and the
|
|
* redirect to login that must follow.
|
|
*/
|
|
public function logoutEveryone(): void
|
|
{
|
|
DB::table('sessions')->truncate();
|
|
|
|
User::query()->eachById(function (User $user) {
|
|
$user->forceFill(['remember_token' => Str::random(60)])->save();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Collapse a raw User-Agent header into a short "Browser · OS" label for the
|
|
* list. Best-effort string sniffing (no extra dependency); falls back to
|
|
* "unknown" copy when the header is empty or unrecognized.
|
|
*/
|
|
private function shortenUserAgent(?string $userAgent): string
|
|
{
|
|
if ($userAgent === null || trim($userAgent) === '') {
|
|
return __('sessions.ua_unknown');
|
|
}
|
|
|
|
$browser = match (true) {
|
|
str_contains($userAgent, 'Edg/') => 'Edge',
|
|
str_contains($userAgent, 'OPR/') || str_contains($userAgent, 'Opera') => 'Opera',
|
|
str_contains($userAgent, 'Firefox') => 'Firefox',
|
|
str_contains($userAgent, 'Chrome') => 'Chrome',
|
|
str_contains($userAgent, 'Safari') => 'Safari',
|
|
default => null,
|
|
};
|
|
|
|
$os = match (true) {
|
|
str_contains($userAgent, 'Windows') => 'Windows',
|
|
str_contains($userAgent, 'Android') => 'Android',
|
|
str_contains($userAgent, 'iPhone') || str_contains($userAgent, 'iPad') => 'iOS',
|
|
str_contains($userAgent, 'Mac OS') || str_contains($userAgent, 'Macintosh') => 'macOS',
|
|
str_contains($userAgent, 'Linux') => 'Linux',
|
|
default => null,
|
|
};
|
|
|
|
$label = trim(implode(' · ', array_filter([$browser, $os])));
|
|
|
|
// Nothing matched → show a trimmed prefix of the raw header so the row is
|
|
// still distinguishable, rather than a useless "unknown".
|
|
return $label !== '' ? $label : Str::limit($userAgent, 40);
|
|
}
|
|
}
|