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:
parent
53e3b270e0
commit
29e68330ca
2 changed files with 93 additions and 26 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue