clusev/app/Services/SessionService.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);
}
}