feat(security): Sicherheitsseite zeigt dieses Gerät und die letzten Anmeldungen

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 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-09 13:35:54 +00:00
parent 53e3b270e0
commit 29e68330ca
2 changed files with 93 additions and 26 deletions

View file

@ -1,15 +1,16 @@
<?php
use App\Actions\Admin\UserImpersonation;
use App\Enums\ActivityType;
use App\Enums\SecurityChange;
use App\Livewire\Concerns\ConfirmsIdentity;
use App\Mail\EmailChangeConfirmation;
use App\Models\ActivityEvent;
use App\Models\User;
use App\Services\Auth\PasswordChanger;
use App\Services\Auth\SecurityNotifier;
use App\Services\Auth\SessionRevocation;
use App\Services\Auth\StrongAuthentication;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
@ -309,11 +310,23 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
'recoveryCodes' => $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
<article class="panel p-5 space-y-2">
<div class="text-[11px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Aktive Sessions') }}
{{ __('Anmeldungen') }}
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)]">{{ $sessions->count() }}</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)]">{{ $loginsLast30Days }}</div>
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Aus den aktuellen Web-Sessions') }}
{{ __('In den letzten 30 Tagen') }}
</div>
</article>
</section>
@ -608,9 +621,9 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Aktive Sessions') }}</span>
<span class="section-eyebrow">{{ __('Geräte und Anmeldungen') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $sessions->count() }} {{ __('Einträge') }}
{{ __('Letzte :count Anmeldungen', ['count' => $recentLogins->count()]) }}
</span>
</div>
<div class="flex flex-col gap-3 px-5 pb-2 pt-3 sm:flex-row sm:items-center sm:justify-between">
@ -625,32 +638,50 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
</div>
<div class="divide-y divide-[color:var(--color-bg-rule)]">
@forelse ($sessions as $session)
<div class="flex flex-col gap-2 px-5 py-3 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ $session->ip_address ?: __('IP unbekannt') }}
{{ __('Dieses Gerät') }}
</div>
<div class="mt-0.5 text-[11.5px] text-[color:var(--color-ink-3)] truncate">
{{ Str::limit($session->user_agent ?: __('User-Agent unbekannt'), 120) }}
{{ Str::limit(request()->userAgent() ?: __('Browser unbekannt'), 120) }}
</div>
</div>
<span class="badge hub">
{{ \Carbon\Carbon::createFromTimestamp($session->last_activity)->diffForHumans() }}
<span class="badge ok">
@if ($currentSessionSince)
{{ __('angemeldet :since', ['since' => $currentSessionSince->diffForHumans()]) }}
@else
{{ __('angemeldet') }}
@endif
</span>
</div>
@empty
<div class="flex flex-col items-center justify-center px-5 py-10 text-center">
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
<flux:icon.shield-check class="size-6" />
@forelse ($recentLogins as $login)
<div class="flex flex-col gap-2 px-5 py-3 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ $login->occurred_at->format('d.m.Y H:i') }}
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)]">{{ __('Keine Sessions gefunden') }}</div>
<p class="mt-1 max-w-md text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Sobald Sessions protokolliert werden, erscheinen sie hier.') }}
</p>
<div class="mt-0.5 text-[11.5px] text-[color:var(--color-ink-3)]">
{{ 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'),
} }}
</div>
</div>
<span class="badge hub">{{ $login->occurred_at->diffForHumans() }}</span>
</div>
@empty
<div class="px-5 py-6 text-center text-[12px] text-[color:var(--color-ink-3)]">
{{ __('Noch keine weiteren Anmeldungen protokolliert.') }}
</div>
@endforelse
</div>
<p class="px-5 pb-4 pt-2 text-[11.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('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.') }}
</p>
</article>
</div>

View file

@ -1,11 +1,13 @@
<?php
use App\Enums\ActivityType;
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Enums\SecurityChange;
use App\Mail\EmailChangeConfirmation;
use App\Mail\SecurityChangeNotice;
use App\Mail\SecurityConfirmationCode;
use App\Models\ActivityEvent;
use App\Models\Category;
use App\Models\Company;
use App\Models\Contact;
@ -185,12 +187,46 @@ test('customer security page renders password and email forms', function () {
Livewire::test('customer.security')
->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([