diff --git a/.env.example b/.env.example index 2c45323..0d13858 100644 --- a/.env.example +++ b/.env.example @@ -26,7 +26,7 @@ DB_PASSWORD=change-me-strong # mariadb container only (compose), not read by Laravel: DB_ROOT_PASSWORD=change-me-strong-root -# ── Cache / Queue / Session via Redis ──────────────────────────────── +# ── Cache / Queue via Redis · Session via database ─────────────────── REDIS_CLIENT=phpredis REDIS_HOST=redis REDIS_PORT=6379 @@ -34,7 +34,8 @@ REDIS_PASSWORD=null CACHE_STORE=redis QUEUE_CONNECTION=redis -SESSION_DRIVER=redis +# database = sessions are listable/revocable in Settings (Redis stays for cache/queue) +SESSION_DRIVER=database SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ diff --git a/app/Livewire/Settings/Sessions.php b/app/Livewire/Settings/Sessions.php index a8b491c..1415da7 100644 --- a/app/Livewire/Settings/Sessions.php +++ b/app/Livewire/Settings/Sessions.php @@ -2,12 +2,84 @@ namespace App\Livewire\Settings; +use App\Services\SessionService; +use Illuminate\Support\Facades\Auth; +use Livewire\Attributes\On; use Livewire\Component; class Sessions extends Component { + /** + * Open the R5 confirm for "sign out other devices". The current session is + * kept; only the other rows are deleted on confirm. The modal writes the + * audit row (session.logout_others) and re-dispatches the apply event. + */ + public function confirmLogoutOthers(): void + { + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => __('sessions.logout_others'), + 'body' => __('sessions.logout_others_body'), + 'confirmLabel' => __('sessions.logout_others'), + 'danger' => true, + 'icon' => 'logout', + 'auditAction' => 'session.logout_others', + 'auditTarget' => Auth::user()?->email, + 'event' => 'sessionsLogoutOthers', + 'notify' => __('sessions.logout_others_notify'), + ], + ); + } + + #[On('sessionsLogoutOthers')] + public function logoutOthers(SessionService $sessions): void + { + $sessions->logoutOtherDevices(Auth::user()); + // List re-renders on the next round trip; no explicit refresh needed. + } + + /** + * Open the R5 confirm for the destructive GLOBAL logout. On confirm every + * session is wiped and every remember_token rotated — including this + * operator's, so the apply handler redirects to login. + */ + public function confirmLogoutAll(): void + { + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => __('sessions.logout_all'), + 'body' => __('sessions.logout_all_body'), + 'confirmLabel' => __('sessions.logout_all'), + 'danger' => true, + 'icon' => 'power', + 'auditAction' => 'session.logout_all', + 'auditTarget' => Auth::user()?->email, + 'event' => 'sessionsLogoutAll', + // Defer the toast: this session is about to be destroyed and the + // user redirected to login, so a toast would never be seen. + 'notify' => '', + ], + ); + } + + #[On('sessionsLogoutAll')] + public function logoutAll(SessionService $sessions) + { + // Truncates all sessions + rotates every remember_token. This logs the + // operator out too, so push them to login (AuthenticateSession would + // otherwise bounce the very next request). + $sessions->logoutEveryone(); + Auth::logout(); + + return $this->redirect(route('login'), navigate: true); + } + public function render() { - return view('livewire.settings.sessions'); + return view('livewire.settings.sessions', [ + 'sessions' => app(SessionService::class)->forUser(Auth::user()), + ]); } } diff --git a/app/Services/SessionService.php b/app/Services/SessionService.php new file mode 100644 index 0000000..8811b74 --- /dev/null +++ b/app/Services/SessionService.php @@ -0,0 +1,123 @@ + + */ + 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); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 52413c5..db64185 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -7,6 +7,7 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Request; +use Illuminate\Session\Middleware\AuthenticateSession; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -32,10 +33,13 @@ return Application::configure(basePath: dirname(__DIR__)) // PanelScheme first (scheme/host enforcement before auth), so an HTTP request to // the active domain is sent straight to HTTPS and a wrong host is refused before - // any auth redirect. + // any auth redirect. AuthenticateSession ties each request to the password hash that + // was current when the session started, so a password change (or a remember-token + // rotation in the session-management actions) invalidates every OTHER session — the + // database session driver makes those sessions enumerable/revocable in Settings. $middleware->web( prepend: [PanelScheme::class], - append: [SetLocale::class, SecurityHeaders::class], + append: [SetLocale::class, SecurityHeaders::class, AuthenticateSession::class], ); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/lang/de/sessions.php b/lang/de/sessions.php new file mode 100644 index 0000000..55a677e --- /dev/null +++ b/lang/de/sessions.php @@ -0,0 +1,24 @@ + 'Sitzungen', + 'subtitle' => 'Aktive Anmeldungen · Geräte abmelden', + + // List + 'this_device' => 'Dieses Gerät', + 'last_active' => 'zuletzt aktiv :time', + 'ua_unknown' => 'Unbekanntes Gerät', + 'none' => 'Keine aktiven Sitzungen.', + 'hint' => 'Meldet überall ab, wo du angemeldet bist.', + + // Actions + 'logout_others' => 'Andere Geräte abmelden', + 'logout_others_body' => 'Alle anderen Sitzungen werden beendet. Dieses Gerät bleibt angemeldet.', + 'logout_others_notify' => 'Andere Geräte abgemeldet.', + + 'logout_all' => 'Alle abmelden (global)', + 'logout_all_body' => 'Beendet ALLE Sitzungen aller Benutzer und macht „Angemeldet bleiben“-Cookies ungültig. Du wirst ebenfalls abgemeldet.', +]; diff --git a/lang/en/sessions.php b/lang/en/sessions.php new file mode 100644 index 0000000..d68d3b8 --- /dev/null +++ b/lang/en/sessions.php @@ -0,0 +1,24 @@ + 'Sessions', + 'subtitle' => 'Active sign-ins · sign out devices', + + // List + 'this_device' => 'This device', + 'last_active' => 'last active :time', + 'ua_unknown' => 'Unknown device', + 'none' => 'No active sessions.', + 'hint' => 'Signs you out everywhere you are signed in.', + + // Actions + 'logout_others' => 'Sign out other devices', + 'logout_others_body' => 'All other sessions will be ended. This device stays signed in.', + 'logout_others_notify' => 'Other devices signed out.', + + 'logout_all' => 'Sign out all (global)', + 'logout_all_body' => 'Ends ALL sessions of all users and invalidates remember-me cookies. You will be signed out too.', +]; diff --git a/resources/views/livewire/settings/sessions.blade.php b/resources/views/livewire/settings/sessions.blade.php index 4fb1310..86843de 100644 --- a/resources/views/livewire/settings/sessions.blade.php +++ b/resources/views/livewire/settings/sessions.blade.php @@ -1,5 +1,45 @@
{{ __('settings.coming_soon') }}
+{{ $session['userAgentShort'] }}
+ @if ($session['isCurrent']) ++ {{ $session['ip'] ?? __('common.none') }} + · {{ __('sessions.last_active', ['time' => $session['lastActivity']->diffForHumans()]) }} +
+{{ __('sessions.none') }}
+ @endforelse +{{ __('sessions.hint') }}
+