From 29e68330ca6b2ca9195ae5a4c8b339c2bb4f6c5f Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 9 Oct 2026 13:35:54 +0000 Subject: [PATCH] =?UTF-8?q?feat(security):=20Sicherheitsseite=20zeigt=20di?= =?UTF-8?q?eses=20Ger=C3=A4t=20und=20die=20letzten=20Anmeldungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Liste „Aktive Sessions“ las die sessions-Tabelle, Live speichert Sessions in Redis — sie war immer leer. Jetzt: „Dieses Gerät“ (seit wann angemeldet) und die letzten fünf Anmeldungen mit Anmeldeweg aus dem Aktivitätsverlauf, ohne IP; Kachel „Anmeldungen in 30 Tagen“ (Roadmap 4.1). Changelog: Verbessert Public: Die Sicherheitsseite zeigt Ihre letzten Anmeldungen und wie Sie sich jeweils angemeldet haben. Co-Authored-By: Claude Opus 5.5 --- .../livewire/customer/security.blade.php | 81 +++++++++++++------ tests/Feature/CustomerProfileSecurityTest.php | 38 ++++++++- 2 files changed, 93 insertions(+), 26 deletions(-) diff --git a/resources/views/livewire/customer/security.blade.php b/resources/views/livewire/customer/security.blade.php index 368ad238..ce0a0fb2 100644 --- a/resources/views/livewire/customer/security.blade.php +++ b/resources/views/livewire/customer/security.blade.php @@ -1,15 +1,16 @@ $recoveryCodes, 'securityProofKind' => $this->securityProofKind($user), 'passkeys' => $user->passkeys()->latest()->get(['id', 'name', 'credential', 'last_used_at', 'created_at']), - 'sessions' => DB::table('sessions') + // Sessions liegen in Redis und lassen sich nicht je Konto aufzählen; + // die Liste zeigt deshalb das aktuelle Gerät und die letzten + // Anmeldungen aus dem Aktivitätsverlauf (ohne IP). + 'currentSessionSince' => ($loggedInAtMs = (int) session(SessionRevocation::SESSION_KEY, 0)) > 0 + ? \Carbon\Carbon::createFromTimestampMs($loggedInAtMs) + : null, + 'recentLogins' => ActivityEvent::query() ->where('user_id', $user->id) - ->orderByDesc('last_activity') + ->where('type', ActivityType::UserLoggedIn) + ->orderByDesc('occurred_at') ->limit(5) - ->get(['id', 'ip_address', 'user_agent', 'last_activity']), + ->get(['occurred_at', 'meta']), + 'loginsLast30Days' => ActivityEvent::query() + ->where('user_id', $user->id) + ->where('type', ActivityType::UserLoggedIn) + ->where('occurred_at', '>=', now()->subDays(30)) + ->count(), ]; } }; ?> @@ -405,11 +418,11 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
- {{ __('Aktive Sessions') }} + {{ __('Anmeldungen') }}
-
{{ $sessions->count() }}
+
{{ $loginsLast30Days }}
- {{ __('Aus den aktuellen Web-Sessions') }} + {{ __('In den letzten 30 Tagen') }}
@@ -608,9 +621,9 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
- {{ __('Aktive Sessions') }} + {{ __('Geräte und Anmeldungen') }} - {{ $sessions->count() }} {{ __('Einträge') }} + {{ __('Letzte :count Anmeldungen', ['count' => $recentLogins->count()]) }}
@@ -625,32 +638,50 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
- @forelse ($sessions as $session) +
+
+
+ {{ __('Dieses Gerät') }} +
+
+ {{ Str::limit(request()->userAgent() ?: __('Browser unbekannt'), 120) }} +
+
+ + @if ($currentSessionSince) + {{ __('angemeldet :since', ['since' => $currentSessionSince->diffForHumans()]) }} + @else + {{ __('angemeldet') }} + @endif + +
+ + @forelse ($recentLogins as $login)
- {{ $session->ip_address ?: __('IP unbekannt') }} + {{ $login->occurred_at->format('d.m.Y H:i') }}
-
- {{ Str::limit($session->user_agent ?: __('User-Agent unbekannt'), 120) }} +
+ {{ match ($login->meta['method'] ?? 'password') { + 'magic-link' => __('Anmeldung per Magic-Link'), + 'google' => __('Anmeldung mit Google'), + 'passkey' => __('Anmeldung mit Passkey'), + 'impersonation' => __('Zugriff durch den Support'), + default => __('Anmeldung mit Passwort'), + } }}
- - {{ \Carbon\Carbon::createFromTimestamp($session->last_activity)->diffForHumans() }} - + {{ $login->occurred_at->diffForHumans() }}
@empty -
-
- -
-
{{ __('Keine Sessions gefunden') }}
-

- {{ __('Sobald Sessions protokolliert werden, erscheinen sie hier.') }} -

+
+ {{ __('Noch keine weiteren Anmeldungen protokolliert.') }}
@endforelse
+

+ {{ __('Laufende Anmeldungen auf anderen Geräten werden nicht einzeln angezeigt. Erkennen Sie eine Anmeldung nicht, ändern Sie Ihr Passwort und melden Sie die anderen Geräte ab.') }} +

diff --git a/tests/Feature/CustomerProfileSecurityTest.php b/tests/Feature/CustomerProfileSecurityTest.php index f304c2b5..94843c30 100644 --- a/tests/Feature/CustomerProfileSecurityTest.php +++ b/tests/Feature/CustomerProfileSecurityTest.php @@ -1,11 +1,13 @@ assertSee('Konto-Sicherheit') ->assertSee('Letzter Login') - ->assertSee('Aktive Sessions') + ->assertSee('Geräte und Anmeldungen') ->assertSee('Passwort ändern') ->assertSee('E-Mail-Adresse ändern') ->assertSee('Zwei-Faktor-Authentifizierung'); }); +test('the security page lists the own recent logins with their method, not those of others', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + $other = User::factory()->create(['is_active' => true]); + + foreach ([['magic-link', 2], ['google', 3], ['passkey', 40]] as [$method, $daysAgo]) { + ActivityEvent::create([ + 'type' => ActivityType::UserLoggedIn, + 'occurred_at' => now()->subDays($daysAgo), + 'user_id' => $customer->id, + 'description' => 'Anmeldung', + 'meta' => ['method' => $method], + ]); + } + ActivityEvent::create([ + 'type' => ActivityType::UserLoggedIn, + 'occurred_at' => now()->subDay(), + 'user_id' => $other->id, + 'description' => 'Anmeldung', + 'meta' => ['method' => 'impersonation'], + ]); + + $this->actingAs($customer); + + Livewire::test('customer.security') + ->assertSee('Dieses Gerät') + ->assertSee('Anmeldung per Magic-Link') + ->assertSee('Anmeldung mit Google') + ->assertSee('Anmeldung mit Passkey') + ->assertDontSee('Zugriff durch den Support') + ->assertViewHas('loginsLast30Days', 2) + ->assertViewHas('recentLogins', fn ($logins) => $logins->count() === 3); +}); + test('customer can change own password through security page', function () { /** @var TestCase $this */ $customer = User::factory()->create([