From 7b4215d0559d6c4629fc3ed689a15bf7e569c379 Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 20 Jun 2026 18:13:12 +0200 Subject: [PATCH] feat(auth): Anmeldeschutz settings tab + unban confirm flow Co-Authored-By: Claude Sonnet 4.6 --- app/Livewire/Settings/LoginProtection.php | 163 ++++++++++++++++++ app/Support/Confirm/ConfirmToken.php | 2 + lang/de/settings.php | 30 ++++ lang/en/settings.php | 30 ++++ .../views/livewire/settings/index.blade.php | 3 + .../settings/login-protection.blade.php | 82 +++++++++ .../Settings/LoginProtectionTabTest.php | 89 ++++++++++ 7 files changed, 399 insertions(+) create mode 100644 app/Livewire/Settings/LoginProtection.php create mode 100644 resources/views/livewire/settings/login-protection.blade.php create mode 100644 tests/Feature/Settings/LoginProtectionTabTest.php diff --git a/app/Livewire/Settings/LoginProtection.php b/app/Livewire/Settings/LoginProtection.php new file mode 100644 index 0000000..e8639dc --- /dev/null +++ b/app/Livewire/Settings/LoginProtection.php @@ -0,0 +1,163 @@ +enabled = $guard->enabled(); + $this->maxretry = $guard->maxretry(); + $this->findtime = $guard->findtime(); + $this->bantime = $guard->bantime(); + $this->whitelist = implode("\n", $guard->whitelist()); + } + + public function save(BruteforceGuard $guard): void + { + $this->validate([ + 'maxretry' => ['required', 'integer', 'min:1', 'max:1000'], + 'findtime' => ['required', 'integer', 'min:1', 'max:1440'], + 'bantime' => ['required', 'integer', 'min:1', 'max:43200'], + 'whitelist' => ['nullable', 'string'], + ]); + + $lines = array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $this->whitelist) ?: []))); + foreach ($lines as $line) { + if (Validator::make(['v' => $line], ['v' => [new ValidIpOrCidr]])->fails()) { + $this->addError('whitelist', __('settings.lp_whitelist_invalid', ['value' => $line])); + + return; + } + } + + Setting::put('bruteforce_enabled', $this->enabled ? '1' : '0'); + Setting::put('bruteforce_maxretry', (string) $this->maxretry); + Setting::put('bruteforce_findtime', (string) $this->findtime); + Setting::put('bruteforce_bantime', (string) $this->bantime); + Setting::put('bruteforce_whitelist', implode("\n", $lines)); + + // Widening the whitelist immediately clears any now-exempt bans. + foreach (BannedIp::all() as $ban) { + if ($guard->isExempt($ban->ip)) { + $guard->unban($ban->ip); + } + } + + $this->audit('auth.protection_updated', null); + $this->dispatch('notify', message: __('settings.lp_saved')); + } + + public function whitelistMyIp(): void + { + $ip = (string) request()->ip(); + $lines = array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $this->whitelist) ?: []))); + if (! in_array($ip, $lines, true)) { + $lines[] = $ip; + } + $this->whitelist = implode("\n", $lines); + } + + public function confirmUnban(string $ip): void + { + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => __('settings.lp_unban_heading'), + 'body' => __('settings.lp_unban_body', ['ip' => $ip]), + 'confirmLabel' => __('settings.lp_unban'), + 'danger' => false, + 'icon' => 'shield', + 'notify' => __('settings.lp_unban_notify'), + 'token' => ConfirmToken::issue('banCleared', ['ip' => $ip], auditTarget: $ip), + ], + ); + } + + #[On('banCleared')] + public function unban(string $confirmToken, BruteforceGuard $guard): void + { + try { + $payload = ConfirmToken::consume($confirmToken, 'banCleared'); + } catch (InvalidConfirmToken) { + return; + } + $ip = (string) $payload['params']['ip']; + $guard->unban($ip); + $this->audit('auth.ip_unbanned', $ip); + } + + public function confirmUnbanAll(): void + { + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => __('settings.lp_unban_all_heading'), + 'body' => __('settings.lp_unban_all_body'), + 'confirmLabel' => __('settings.lp_unban_all'), + 'danger' => true, + 'icon' => 'shield', + 'notify' => __('settings.lp_unban_all_notify'), + 'token' => ConfirmToken::issue('bansClearedAll'), + ], + ); + } + + #[On('bansClearedAll')] + public function unbanAll(string $confirmToken): void + { + try { + ConfirmToken::consume($confirmToken, 'bansClearedAll'); + } catch (InvalidConfirmToken) { + return; // forged / replayed / direct-bypass attempt — no-op + } + foreach (BannedIp::query()->pluck('ip') as $ip) { + Cache::forget('bruteforce:banned:'.$ip); + } + BannedIp::query()->delete(); + $this->audit('auth.ip_unbanned', 'all'); + } + + private function audit(string $action, ?string $target): void + { + AuditEvent::create([ + 'user_id' => Auth::id(), + 'actor' => Auth::user()?->name ?? 'system', + 'action' => $action, + 'target' => $target, + 'ip' => request()->ip(), + ]); + } + + public function render(BruteforceGuard $guard) + { + return view('livewire.settings.login-protection', [ + 'bans' => BannedIp::query()->where('banned_until', '>', now())->orderByDesc('banned_until')->get(), + 'currentIp' => (string) request()->ip(), + 'currentIpExempt' => $guard->isExempt((string) request()->ip()), + ]); + } +} diff --git a/app/Support/Confirm/ConfirmToken.php b/app/Support/Confirm/ConfirmToken.php index 5048e36..53c7f01 100644 --- a/app/Support/Confirm/ConfirmToken.php +++ b/app/Support/Confirm/ConfirmToken.php @@ -54,6 +54,8 @@ class ConfirmToken 'sessionsLogoutOthers', 'sessionsLogoutAll', 'twoFactorDisabled', + 'banCleared', + 'bansClearedAll', ]; /** How long an issued confirm stays valid (seconds) — generous for a human click. */ diff --git a/lang/de/settings.php b/lang/de/settings.php index 0336428..a10b6f7 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -66,4 +66,34 @@ return [ // IP/CIDR whitelist validation 'lp_whitelist_invalid' => ':value ist keine gültige IP/CIDR.', + + // Anmeldeschutz tab + 'tab_login_protection' => 'Anmeldeschutz', + 'lp_title' => 'Anmeldeschutz', + 'lp_subtitle' => 'Sperrt IP-Adressen nach zu vielen fehlgeschlagenen Anmeldeversuchen.', + 'lp_enabled' => 'Aktiv', + 'lp_maxretry' => 'Max. Versuche', + 'lp_findtime' => 'Zeitfenster (Min.)', + 'lp_bantime' => 'Bann-Dauer (Min.)', + 'lp_whitelist' => 'Whitelist (IP/CIDR, eine pro Zeile)', + 'lp_whitelist_hint' => 'Diese Adressen werden nie gezählt oder gesperrt. Loopback ist immer ausgenommen.', + 'lp_current_ip' => 'Deine aktuelle IP', + 'lp_current_ip_exempt' => 'ausgenommen', + 'lp_whitelist_my_ip' => 'Meine IP zur Whitelist hinzufügen', + 'lp_save' => 'Speichern', + 'lp_saved' => 'Anmeldeschutz gespeichert.', + 'lp_bans_title' => 'Aktive Sperren', + 'lp_ban_reason' => 'Grund', + 'lp_ban_attempts' => 'Versuche', + 'lp_ban_until' => 'gesperrt bis :time', + 'lp_no_bans' => 'Keine aktiven Sperren.', + 'lp_unban' => 'Entsperren', + 'lp_unban_heading' => 'IP entsperren?', + 'lp_unban_body' => ':ip wird wieder zugelassen.', + 'lp_unban_notify' => 'IP entsperrt.', + 'lp_unban_all' => 'Alle entsperren', + 'lp_unban_all_heading' => 'Alle Sperren aufheben?', + 'lp_unban_all_body' => 'Alle aktuell gesperrten IP-Adressen werden wieder zugelassen.', + 'lp_unban_all_notify' => 'Alle Sperren aufgehoben.', + 'lp_lockout_note' => 'Eingeloggte Operatoren werden nie geblockt und können hier ihre eigene IP entsperren. Notfall: clusev unban auf der Host-Shell.', ]; diff --git a/lang/en/settings.php b/lang/en/settings.php index d4fee99..9ce9edb 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -66,4 +66,34 @@ return [ // IP/CIDR whitelist validation 'lp_whitelist_invalid' => ':value is not a valid IP/CIDR.', + + // Login protection tab + 'tab_login_protection' => 'Login protection', + 'lp_title' => 'Login protection', + 'lp_subtitle' => 'Blocks IP addresses after too many failed sign-in attempts.', + 'lp_enabled' => 'Enabled', + 'lp_maxretry' => 'Max. attempts', + 'lp_findtime' => 'Time window (min.)', + 'lp_bantime' => 'Ban duration (min.)', + 'lp_whitelist' => 'Whitelist (IP/CIDR, one per line)', + 'lp_whitelist_hint' => 'These addresses are never counted or blocked. Loopback is always exempt.', + 'lp_current_ip' => 'Your current IP', + 'lp_current_ip_exempt' => 'exempt', + 'lp_whitelist_my_ip' => 'Add my IP to the whitelist', + 'lp_save' => 'Save', + 'lp_saved' => 'Login protection saved.', + 'lp_bans_title' => 'Active blocks', + 'lp_ban_reason' => 'Reason', + 'lp_ban_attempts' => 'Attempts', + 'lp_ban_until' => 'blocked until :time', + 'lp_no_bans' => 'No active blocks.', + 'lp_unban' => 'Unblock', + 'lp_unban_heading' => 'Unblock IP?', + 'lp_unban_body' => ':ip will be allowed again.', + 'lp_unban_notify' => 'IP unblocked.', + 'lp_unban_all' => 'Unblock all', + 'lp_unban_all_heading' => 'Clear all blocks?', + 'lp_unban_all_body' => 'All currently blocked IP addresses will be allowed again.', + 'lp_unban_all_notify' => 'All blocks cleared.', + 'lp_lockout_note' => 'Logged-in operators are never blocked and can unblock their own IP here. Emergency: clusev unban on the host shell.', ]; diff --git a/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php index d365e53..af7c21d 100644 --- a/resources/views/livewire/settings/index.blade.php +++ b/resources/views/livewire/settings/index.blade.php @@ -5,6 +5,7 @@ $tabs = [ ['key' => 'profile', 'label' => __('settings.tab_profile'), 'icon' => 'settings'], ['key' => 'security', 'label' => __('settings.tab_security'), 'icon' => 'shield'], + ['key' => 'login-protection', 'label' => __('settings.tab_login_protection'), 'icon' => 'shield'], ['key' => 'users', 'label' => __('settings.tab_users'), 'icon' => 'user-plus'], ['key' => 'sessions', 'label' => __('settings.tab_sessions'), 'icon' => 'logout'], ['key' => 'email', 'label' => __('settings.tab_email'), 'icon' => 'mail'], @@ -53,9 +54,11 @@
@if ($tab === 'profile') @elseif ($tab === 'security') + @elseif ($tab === 'login-protection') @elseif ($tab === 'users') @elseif ($tab === 'sessions') @elseif ($tab === 'email') + @else @endif
diff --git a/resources/views/livewire/settings/login-protection.blade.php b/resources/views/livewire/settings/login-protection.blade.php new file mode 100644 index 0000000..a6dbc63 --- /dev/null +++ b/resources/views/livewire/settings/login-protection.blade.php @@ -0,0 +1,82 @@ +@php + $field = 'h-9 w-full rounded-md border border-line bg-inset px-3 font-sans text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none'; + $label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3'; + $err = 'mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline'; +@endphp + +
+ +
+ + +
+
+ + + @error('maxretry')

{{ $message }}

@enderror +
+
+ + + @error('findtime')

{{ $message }}

@enderror +
+
+ + + @error('bantime')

{{ $message }}

@enderror +
+
+ +
+ + +

{{ __('settings.lp_whitelist_hint') }}

+ @error('whitelist')

{{ $message }}

@enderror +
+ +
+

+ {{ __('settings.lp_current_ip') }}: {{ $currentIp }} + @if ($currentIpExempt) · {{ __('settings.lp_current_ip_exempt') }} @endif + +

+ {{ __('settings.lp_save') }} +
+
+
+ + +
+ @forelse ($bans as $ban) +
+
+

{{ $ban->ip }}

+

+ {{ __('settings.lp_ban_reason') }}: {{ $ban->reason ?? '—' }} + · {{ __('settings.lp_ban_attempts') }}: {{ $ban->attempts }} + · {{ __('settings.lp_ban_until', ['time' => $ban->banned_until->diffForHumans()]) }} +

+
+ + {{ __('settings.lp_unban') }} + +
+ @empty +

{{ __('settings.lp_no_bans') }}

+ @endforelse +
+ @if ($bans->isNotEmpty()) +
+ {{ __('settings.lp_unban_all') }} +
+ @endif +
+ +

+ + {{ __('settings.lp_lockout_note') }} +

+
diff --git a/tests/Feature/Settings/LoginProtectionTabTest.php b/tests/Feature/Settings/LoginProtectionTabTest.php new file mode 100644 index 0000000..863fd45 --- /dev/null +++ b/tests/Feature/Settings/LoginProtectionTabTest.php @@ -0,0 +1,89 @@ +create(); + Livewire::actingAs($user)->test(LoginProtection::class) + ->set('enabled', false)->set('maxretry', 7)->set('findtime', 15)->set('bantime', 120) + ->set('whitelist', '203.0.113.0/24') + ->call('save'); + + $this->assertSame('0', Setting::get('bruteforce_enabled')); + $this->assertSame('7', Setting::get('bruteforce_maxretry')); + $this->assertSame('203.0.113.0/24', Setting::get('bruteforce_whitelist')); + } + + public function test_invalid_whitelist_is_rejected(): void + { + $user = User::factory()->create(); + Livewire::actingAs($user)->test(LoginProtection::class) + ->set('whitelist', 'junk-not-a-cidr')->call('save') + ->assertHasErrors('whitelist'); + $this->assertNull(Setting::get('bruteforce_maxretry')); + } + + public function test_table_lists_active_bans_and_unban_removes_one(): void + { + $user = User::factory()->create(); + BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]); + + // Must authenticate before issuing so the token's uid matches Auth::id() at consume time. + $this->actingAs($user); + $token = ConfirmToken::issue('banCleared', ['ip' => '203.0.113.5']); + ConfirmToken::confirm($token); + Livewire::actingAs($user)->test(LoginProtection::class) + ->assertSee('203.0.113.5') + ->call('unban', $token); + + $this->assertDatabaseCount('banned_ips', 0); + $this->assertDatabaseHas('audit_events', ['action' => 'auth.ip_unbanned', 'target' => '203.0.113.5']); + } + + public function test_widening_whitelist_clears_now_exempt_bans(): void + { + $user = User::factory()->create(); + BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]); + + Livewire::actingAs($user)->test(LoginProtection::class) + ->set('whitelist', '203.0.113.0/24')->call('save'); + + $this->assertDatabaseCount('banned_ips', 0); // 203.0.113.5 now exempt → purged + } + + public function test_whitelist_my_ip_adds_current_ip(): void + { + $user = User::factory()->create(); + + // Inject the public IP into every request bound to the container, + // using the same rebinding mechanism as BruteforceHooksTest (Task 6). + app()->rebinding('request', static function ($app, $request) { + $request->server->set('REMOTE_ADDR', '198.51.100.7'); + }); + + Livewire::actingAs($user)->test(LoginProtection::class) + ->call('whitelistMyIp') + ->assertSet('whitelist', fn ($w) => str_contains($w, '198.51.100.7')); + } +}