feat(auth): Anmeldeschutz settings tab + unban confirm flow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-20 18:13:12 +02:00
parent 96f86e2761
commit 7b4215d055
7 changed files with 399 additions and 0 deletions

View File

@ -0,0 +1,163 @@
<?php
namespace App\Livewire\Settings;
use App\Models\AuditEvent;
use App\Models\BannedIp;
use App\Models\Setting;
use App\Rules\ValidIpOrCidr;
use App\Services\BruteforceGuard;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Validator;
use Livewire\Attributes\On;
use Livewire\Component;
class LoginProtection extends Component
{
public bool $enabled = true;
public int $maxretry = 10;
public int $findtime = 10;
public int $bantime = 60;
public string $whitelist = '';
public function mount(BruteforceGuard $guard): void
{
$this->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()),
]);
}
}

View File

@ -54,6 +54,8 @@ class ConfirmToken
'sessionsLogoutOthers',
'sessionsLogoutAll',
'twoFactorDisabled',
'banCleared',
'bansClearedAll',
];
/** How long an issued confirm stays valid (seconds) — generous for a human click. */

View File

@ -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 <ip> auf der Host-Shell.',
];

View File

@ -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 <ip> on the host shell.',
];

View File

@ -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 @@
<div class="min-w-0">
@if ($tab === 'profile') <livewire:settings.profile />
@elseif ($tab === 'security') <livewire:settings.security />
@elseif ($tab === 'login-protection') <livewire:settings.login-protection />
@elseif ($tab === 'users') <livewire:settings.users />
@elseif ($tab === 'sessions') <livewire:settings.sessions />
@elseif ($tab === 'email') <livewire:settings.email />
@else <livewire:settings.profile />
@endif
</div>
</div>

View File

@ -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
<div class="space-y-5">
<x-panel :title="__('settings.lp_title')" :subtitle="__('settings.lp_subtitle')">
<form wire:submit="save" class="space-y-4">
<label class="flex items-center gap-3">
<input type="checkbox" wire:model="enabled" class="h-4 w-4 rounded border-line bg-inset text-accent focus:ring-0" />
<span class="text-sm text-ink">{{ __('settings.lp_enabled') }}</span>
</label>
<div class="grid gap-4 sm:grid-cols-3">
<div>
<label class="{{ $label }}">{{ __('settings.lp_maxretry') }}</label>
<input wire:model="maxretry" type="text" inputmode="numeric" class="{{ $field }}" />
@error('maxretry') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label class="{{ $label }}">{{ __('settings.lp_findtime') }}</label>
<input wire:model="findtime" type="text" inputmode="numeric" class="{{ $field }}" />
@error('findtime') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label class="{{ $label }}">{{ __('settings.lp_bantime') }}</label>
<input wire:model="bantime" type="text" inputmode="numeric" class="{{ $field }}" />
@error('bantime') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
</div>
<div>
<label class="{{ $label }}">{{ __('settings.lp_whitelist') }}</label>
<textarea wire:model="whitelist" rows="4" class="{{ $field }} h-auto py-2 font-mono"></textarea>
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('settings.lp_whitelist_hint') }}</p>
@error('whitelist') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-line pt-4">
<p class="font-mono text-[11px] text-ink-4">
{{ __('settings.lp_current_ip') }}: <span class="text-ink-2">{{ $currentIp }}</span>
@if ($currentIpExempt) <span class="text-online">· {{ __('settings.lp_current_ip_exempt') }}</span> @endif
<button type="button" wire:click="whitelistMyIp" class="ml-2 text-accent-text hover:underline">{{ __('settings.lp_whitelist_my_ip') }}</button>
</p>
<x-btn variant="primary" size="lg" type="submit">{{ __('settings.lp_save') }}</x-btn>
</div>
</form>
</x-panel>
<x-panel :title="__('settings.lp_bans_title')">
<div class="divide-y divide-line">
@forelse ($bans as $ban)
<div class="flex flex-wrap items-center gap-3 py-3">
<div class="min-w-0 flex-1">
<p class="truncate font-mono text-sm text-ink">{{ $ban->ip }}</p>
<p class="truncate font-mono text-[11px] text-ink-3">
{{ __('settings.lp_ban_reason') }}: {{ $ban->reason ?? '—' }}
· {{ __('settings.lp_ban_attempts') }}: {{ $ban->attempts }}
· {{ __('settings.lp_ban_until', ['time' => $ban->banned_until->diffForHumans()]) }}
</p>
</div>
<x-modal-trigger variant="secondary" class="shrink-0" action="confirmUnban('{{ $ban->ip }}')">
{{ __('settings.lp_unban') }}
</x-modal-trigger>
</div>
@empty
<p class="py-3 font-mono text-[11px] text-ink-4">{{ __('settings.lp_no_bans') }}</p>
@endforelse
</div>
@if ($bans->isNotEmpty())
<div class="flex justify-end border-t border-line pt-4">
<x-modal-trigger variant="danger-soft" action="confirmUnbanAll">{{ __('settings.lp_unban_all') }}</x-modal-trigger>
</div>
@endif
</x-panel>
<p class="flex items-start gap-2 px-1 font-mono text-[11px] text-ink-4">
<x-icon name="shield" class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{{ __('settings.lp_lockout_note') }}</span>
</p>
</div>

View File

@ -0,0 +1,89 @@
<?php
namespace Tests\Feature\Settings;
use App\Livewire\Settings\LoginProtection;
use App\Models\BannedIp;
use App\Models\Setting;
use App\Models\User;
use App\Support\Confirm\ConfirmToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
use Tests\TestCase;
class LoginProtectionTabTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
public function test_saving_persists_cast_settings(): void
{
$user = User::factory()->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'));
}
}