From 0cdc6c3a7bbaf50b82ca629a9f813945133ab501 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 23:34:00 +0200 Subject: [PATCH] feat(sessions): database sessions + list/revoke (other devices, per-user, global) with remember-token rotation Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 5 +- app/Livewire/Settings/Sessions.php | 74 +++++++- app/Services/SessionService.php | 123 +++++++++++++ bootstrap/app.php | 8 +- lang/de/sessions.php | 24 +++ lang/en/sessions.php | 24 +++ .../livewire/settings/sessions.blade.php | 44 ++++- tests/Feature/SessionManagementTest.php | 174 ++++++++++++++++++ 8 files changed, 469 insertions(+), 7 deletions(-) create mode 100644 app/Services/SessionService.php create mode 100644 lang/de/sessions.php create mode 100644 lang/en/sessions.php create mode 100644 tests/Feature/SessionManagementTest.php 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') }}

+ +
+
+ @forelse ($sessions as $session) +
+ $session['isCurrent'], + 'border-line bg-inset text-ink-3' => ! $session['isCurrent'], + ])> + + +
+
+

{{ $session['userAgentShort'] }}

+ @if ($session['isCurrent']) + {{ __('sessions.this_device') }} + @endif +
+

+ {{ $session['ip'] ?? __('common.none') }} + · {{ __('sessions.last_active', ['time' => $session['lastActivity']->diffForHumans()]) }} +

+
+
+ @empty +

{{ __('sessions.none') }}

+ @endforelse +
+ +
+

{{ __('sessions.hint') }}

+
+ + {{ __('sessions.logout_others') }} + + + {{ __('sessions.logout_all') }} + +
+
+
diff --git a/tests/Feature/SessionManagementTest.php b/tests/Feature/SessionManagementTest.php new file mode 100644 index 0000000..192b6be --- /dev/null +++ b/tests/Feature/SessionManagementTest.php @@ -0,0 +1,174 @@ + 'database']); + } + + /** Insert a raw session row for $user; returns the generated id. */ + private function seedSession(User $user, string $id, ?string $ip = '203.0.113.1', ?string $ua = 'Mozilla/5.0 Chrome', int $ago = 0): string + { + DB::table('sessions')->insert([ + 'id' => $id, + 'user_id' => $user->id, + 'ip_address' => $ip, + 'user_agent' => $ua, + 'payload' => base64_encode(serialize([])), + 'last_activity' => now()->subSeconds($ago)->getTimestamp(), + ]); + + return $id; + } + + public function test_for_user_returns_rows_with_current_flagged(): void + { + $user = User::factory()->create(); + $other = User::factory()->create(); + + $currentId = Session::getId(); + $this->seedSession($user, $currentId, ago: 0); + $this->seedSession($user, 'sess-old', ago: 600); + $this->seedSession($other, 'sess-other'); // must NOT appear + + $rows = app(SessionService::class)->forUser($user); + + $this->assertCount(2, $rows); + // Newest activity first. + $this->assertSame($currentId, $rows[0]['id']); + $this->assertTrue($rows[0]['isCurrent']); + $this->assertSame('sess-old', $rows[1]['id']); + $this->assertFalse($rows[1]['isCurrent']); + $this->assertSame('203.0.113.1', $rows[0]['ip']); + $this->assertStringContainsString('Chrome', $rows[0]['userAgentShort']); + $this->assertTrue($rows[0]['lastActivity']->isToday()); + } + + public function test_logout_other_devices_deletes_others_keeps_current(): void + { + $user = User::factory()->create(); + $currentId = Session::getId(); + $this->seedSession($user, $currentId); + $this->seedSession($user, 'sess-b'); + $this->seedSession($user, 'sess-c'); + + app(SessionService::class)->logoutOtherDevices($user); + + $this->assertDatabaseHas('sessions', ['id' => $currentId]); + $this->assertDatabaseMissing('sessions', ['id' => 'sess-b']); + $this->assertDatabaseMissing('sessions', ['id' => 'sess-c']); + } + + public function test_logout_user_everywhere_deletes_all_and_rotates_token(): void + { + $user = User::factory()->create(['remember_token' => 'original-token']); + $this->seedSession($user, Session::getId()); + $this->seedSession($user, 'sess-b'); + + app(SessionService::class)->logoutUserEverywhere($user); + + $this->assertSame(0, DB::table('sessions')->where('user_id', $user->id)->count()); + $this->assertNotSame('original-token', $user->fresh()->remember_token); + $this->assertNotNull($user->fresh()->remember_token); + } + + public function test_logout_everyone_truncates_and_rotates_all_tokens(): void + { + $a = User::factory()->create(['remember_token' => 'token-a']); + $b = User::factory()->create(['remember_token' => 'token-b']); + $this->seedSession($a, 'sess-a'); + $this->seedSession($b, 'sess-b'); + + app(SessionService::class)->logoutEveryone(); + + $this->assertSame(0, DB::table('sessions')->count()); + $this->assertNotSame('token-a', $a->fresh()->remember_token); + $this->assertNotSame('token-b', $b->fresh()->remember_token); + } + + public function test_component_lists_the_users_sessions(): void + { + $user = User::factory()->create(); + $this->seedSession($user, Session::getId()); + + Livewire::actingAs($user)->test(Sessions::class) + ->assertOk() + ->assertViewHas('sessions', fn ($sessions) => count($sessions) === 1); + } + + public function test_logout_others_action_confirms_then_deletes(): void + { + $user = User::factory()->create(); + $current = Session::getId(); + $this->seedSession($user, $current); + $this->seedSession($user, 'sess-other'); + + // The button opens the R5 confirm modal carrying the apply event + audit descriptor. + Livewire::actingAs($user)->test(Sessions::class) + ->call('confirmLogoutOthers') + ->assertDispatched('openModal'); + + // The confirm modal re-dispatches the apply event; simulate that. + Livewire::actingAs($user)->test(Sessions::class) + ->call('logoutOthers'); + + $this->assertDatabaseHas('sessions', ['id' => $current]); + $this->assertDatabaseMissing('sessions', ['id' => 'sess-other']); + } + + public function test_logout_all_action_truncates_and_redirects_to_login(): void + { + $user = User::factory()->create(['remember_token' => 'tok']); + $this->seedSession($user, Session::getId()); + + Livewire::actingAs($user)->test(Sessions::class) + ->call('confirmLogoutAll') + ->assertDispatched('openModal'); + + Livewire::actingAs($user)->test(Sessions::class) + ->call('logoutAll') + ->assertRedirect(route('login')); + + $this->assertSame(0, DB::table('sessions')->count()); + $this->assertNotSame('tok', $user->fresh()->remember_token); + } + + public function test_confirm_modal_writes_audit_row_on_confirm(): void + { + // The generic confirm modal persists the audit row from the descriptor the + // Sessions component passes (mirrors Settings\Security). Exercise it directly. + $user = User::factory()->create(); + + Livewire::actingAs($user)->test(ConfirmAction::class, [ + 'auditAction' => 'session.logout_others', + 'auditTarget' => $user->email, + 'event' => 'sessionsLogoutOthers', + ])->call('confirm'); + + $this->assertDatabaseHas('audit_events', [ + 'action' => 'session.logout_others', + 'target' => $user->email, + ]); + $this->assertSame(1, AuditEvent::where('action', 'session.logout_others')->count()); + } +}