feat(auth): Anmeldeschutz settings tab + unban confirm flow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>feat/v1-foundation
parent
96f86e2761
commit
7b4215d055
|
|
@ -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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -54,6 +54,8 @@ class ConfirmToken
|
|||
'sessionsLogoutOthers',
|
||||
'sessionsLogoutAll',
|
||||
'twoFactorDisabled',
|
||||
'banCleared',
|
||||
'bansClearedAll',
|
||||
];
|
||||
|
||||
/** How long an issued confirm stays valid (seconds) — generous for a human click. */
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue