From 9756f9d46313cf851f5c0436eb6d83e9db6f177a Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 20 Jun 2026 17:20:52 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20implementation=20plan=20=E2=80=94=20con?= =?UTF-8?q?trol-plane=20brute-force=20ban=20(Anmeldeschutz)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9-task TDD plan: BannedIp model, BruteforceGuard (inet_pton CIDR + cache), ValidIpOrCidr rule, guests-only BlockBannedIp middleware + 403 page, Login/2FA failure hooks + audit, clusev:unban CLI, Anmeldeschutz settings tab with R5 confirm-modal unban. Spec erratum: middleware is appended (not prepended) so Auth::guest() resolves for the guests-only check. Co-Authored-By: Claude Opus 4.8 --- ...2026-06-20-control-plane-bruteforce-ban.md | 1388 +++++++++++++++++ ...-20-control-plane-bruteforce-ban-design.md | 11 +- 2 files changed, 1395 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-20-control-plane-bruteforce-ban.md diff --git a/docs/superpowers/plans/2026-06-20-control-plane-bruteforce-ban.md b/docs/superpowers/plans/2026-06-20-control-plane-bruteforce-ban.md new file mode 100644 index 0000000..8f4f671 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-control-plane-bruteforce-ban.md @@ -0,0 +1,1388 @@ +# Control-plane brute-force IP ban (Anmeldeschutz) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an application-level persistent IP ban that blocks unauthenticated requests from IPs with too many failed Clusev logins, configurable from a new "Anmeldeschutz" settings tab. + +**Architecture:** A `BannedIp` table + `BruteforceGuard` service (cache-counter → DB ban, inet_pton CIDR exemptions, cached ban check) feeds an **appended** `BlockBannedIp` web-group middleware that 403s **guest** requests from banned IPs (authenticated operators pass, so they're never self-locked-out and can unban from the tab). Login + 2FA failure hooks drive the guard and write audit events. A `clusev:unban` CLI is the host escape hatch. + +**Tech Stack:** Laravel 13, Livewire v3 (class-based), Tailwind v4, PHPUnit, Pint, wire-elements/modal (R5 confirm via `ConfirmToken`). Spec: `docs/superpowers/specs/2026-06-20-control-plane-bruteforce-ban-design.md`. + +## Pre-flight & corrections + +- Dev stack up (`docker compose up -d`); **run all tooling in the container (R8)**. Branch `feat/v1-foundation`. +- **Spec erratum (this plan supersedes it):** the middleware is **appended** to the web group, not prepended. Guests-only enforcement needs `Auth::guest()`, which requires the session middleware (`StartSession`) to have already run — a *prepended* middleware runs before it and would see every request as a guest. Appended (after `AuthenticateSession`), the auth guard resolves correctly. `request()->ip()` is still correct because `trustProxies` is a global middleware that runs before the whole web group. +- Test isolation (memory: view-cache race): if Blade-compile flakiness appears, re-run with `-e VIEW_COMPILED_PATH=/tmp/views-test`. + +## File structure + +| File | Responsibility | +|---|---| +| `app/Models/BannedIp.php` + migration | active-ban storage | +| `app/Services/BruteforceGuard.php` | record/ban/isBanned/isExempt/unban + CIDR + config casts | +| `app/Rules/ValidIpOrCidr.php` | whitelist field validation | +| `app/Http/Middleware/BlockBannedIp.php` | guests-only 403 enforcement | +| `resources/views/errors/blocked.blade.php` + `lang/{de,en}/errors.php` | the 403 page | +| `app/Console/Commands/UnbanIp.php` + `docker/clusev/clusev` | host escape | +| `app/Livewire/Settings/LoginProtection.php` + view | the settings tab | +| `app/Support/Confirm/ConfirmToken.php` (modify) | allowlist the unban confirm events | +| `bootstrap/app.php`, `Login.php`, `CompletesTwoFactorChallenge.php`, `settings/index.blade.php`, `lang/{de,en}/settings.php` (modify) | wiring + hooks + tab + i18n | + +--- + +### Task 1: `BannedIp` model + migration + +**Files:** Create `app/Models/BannedIp.php`, `database/migrations/2026_06_20_000001_create_banned_ips_table.php`; Test `tests/Feature/BruteforceBannedIpTest.php` + +- [ ] **Step 1: Failing test** + +```php + '1.2.3.4', 'banned_until' => now()->addHour(), 'attempts' => 10]); + BannedIp::create(['ip' => '5.6.7.8', 'banned_until' => now()->subHour(), 'attempts' => 10]); + + $active = BannedIp::active()->pluck('ip')->all(); + + $this->assertSame(['1.2.3.4'], $active); + } +} +``` + +- [ ] **Step 2: Run → fails** (`docker compose exec -T app php artisan test --filter=BruteforceBannedIpTest`) — "Class BannedIp not found". + +- [ ] **Step 3: Migration** + +```php +id(); + $table->string('ip', 45)->unique(); + $table->timestamp('banned_until'); + $table->string('reason')->nullable(); + $table->unsignedInteger('attempts')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('banned_ips'); + } +}; +``` + +- [ ] **Step 4: Model** (`app/Models/BannedIp.php`) + +```php + 'datetime']; + + /** Non-expired bans only. */ + public function scopeActive(Builder $query): Builder + { + return $query->where('banned_until', '>', now()); + } +} +``` + +- [ ] **Step 5: Migrate + run → passes** + +```bash +docker compose exec -T app php artisan migrate +docker compose exec -T app php artisan test --filter=BruteforceBannedIpTest +``` + +- [ ] **Step 6: Pint + commit** + +```bash +docker compose exec -T app ./vendor/bin/pint app/Models/BannedIp.php database/migrations tests/Feature/BruteforceBannedIpTest.php +git add app/Models/BannedIp.php database/migrations/*_create_banned_ips_table.php tests/Feature/BruteforceBannedIpTest.php +git commit -m "feat(auth): BannedIp model + migration" +``` + +--- + +### Task 2: `BruteforceGuard` — CIDR exemptions + +The safety core: which IPs are never counted/banned. `matchesCidr` mirrors `Fail2banService::sameIp()` (inet_pton). + +**Files:** Create `app/Services/BruteforceGuard.php`; Test `tests/Feature/BruteforceGuardExemptTest.php` + +- [ ] **Step 1: Failing test** + +```php +guard(); + $this->assertTrue($g->isExempt('127.0.0.1')); + $this->assertTrue($g->isExempt('::1')); + $this->assertTrue($g->isExempt('0:0:0:0:0:0:0:1')); // alternate spelling + } + + public function test_default_private_whitelist_is_exempt_public_is_not(): void + { + $g = $this->guard(); + $this->assertTrue($g->isExempt('192.168.4.20')); // default whitelist + $this->assertTrue($g->isExempt('10.9.9.9')); + $this->assertFalse($g->isExempt('203.0.113.7')); // public + } + + public function test_invalid_ip_is_treated_as_exempt(): void + { + $this->assertTrue($this->guard()->isExempt('not-an-ip')); + } + + public function test_custom_whitelist_cidr_matches(): void + { + Setting::put('bruteforce_whitelist', "203.0.113.0/24"); + $this->assertTrue($this->guard()->isExempt('203.0.113.50')); + $this->assertFalse($this->guard()->isExempt('203.0.114.50')); + } +} +``` + +- [ ] **Step 2: Run → fails.** + +- [ ] **Step 3: Implement** (`app/Services/BruteforceGuard.php`) + +```php +whitelist()) as $cidr) { + if ($this->matchesCidr($ip, $cidr)) { + return true; + } + } + + return false; + } + + public function record(?string $ip, string $reason): void + { + if (! $this->enabled() || ! $ip || filter_var($ip, FILTER_VALIDATE_IP) === false || $this->isExempt($ip)) { + return; + } + + $canonical = $this->canonical($ip); + $key = 'bruteforce:'.md5($canonical); + RateLimiter::hit($key, $this->findtime() * 60); // minutes → seconds + $hits = RateLimiter::attempts($key); + + if ($hits < $this->maxretry()) { + return; + } + + // Atomic upsert: two concurrent requests at the threshold must not collide on the + // unique ip index. Eloquent upsert does not auto-manage timestamps for bulk ops. + BannedIp::upsert( + [[ + 'ip' => $canonical, + 'banned_until' => now()->addMinutes($this->bantime()), + 'reason' => $reason, + 'attempts' => $hits, + 'created_at' => now(), + 'updated_at' => now(), + ]], + ['ip'], + ['banned_until', 'reason', 'attempts', 'updated_at'], + ); + RateLimiter::clear($key); + Cache::forget('bruteforce:banned:'.$canonical); + + AuditEvent::create([ + 'user_id' => null, + 'actor' => 'system', + 'action' => 'auth.ip_banned', + 'target' => $canonical, + 'ip' => $canonical, + 'meta' => ['reason' => $reason, 'attempts' => $hits], + ]); + } + + public function isBanned(string $ip): bool + { + if ($this->isExempt($ip)) { + return false; // exempt short-circuits — whitelisting unblocks immediately + } + + $canonical = $this->canonical($ip); + + return Cache::remember('bruteforce:banned:'.$canonical, self::CHECK_TTL, fn () => BannedIp::query() + ->where('ip', $canonical) + ->where('banned_until', '>', now()) + ->exists()); + } + + public function unban(string $ip): void + { + $canonical = $this->canonical($ip); + BannedIp::query()->where('ip', $canonical)->delete(); + Cache::forget('bruteforce:banned:'.$canonical); + } + + /** inet_pton-based CIDR / single-IP match (IPv4, IPv6, IPv4-mapped). */ + public function matchesCidr(string $ip, string $cidr): bool + { + if (! str_contains($cidr, '/')) { + $a = @inet_pton($ip); + $b = @inet_pton($cidr); + + return $a !== false && $b !== false && $a === $b; + } + + [$subnet, $maskStr] = explode('/', $cidr, 2); + $pip = @inet_pton($ip); + $psub = @inet_pton($subnet); + if ($pip === false || $psub === false || strlen($pip) !== strlen($psub) || ! ctype_digit($maskStr)) { + return false; // v4-vs-v6 mismatch or junk mask + } + + $mask = (int) $maskStr; + $bytes = intdiv($mask, 8); + $rem = $mask % 8; + if ($bytes > 0 && substr($pip, 0, $bytes) !== substr($psub, 0, $bytes)) { + return false; + } + if ($rem === 0) { + return true; + } + $b = 0xFF & (0xFF << (8 - $rem)); + + return (ord($pip[$bytes]) & $b) === (ord($psub[$bytes]) & $b); + } + + private function canonical(string $ip): string + { + $packed = @inet_pton($ip); + + return $packed === false ? $ip : (string) inet_ntop($packed); + } +} +``` + +- [ ] **Step 4: Run → passes.** + +- [ ] **Step 5: Pint + commit** + +```bash +docker compose exec -T app ./vendor/bin/pint app/Services/BruteforceGuard.php tests/Feature/BruteforceGuardExemptTest.php +git add app/Services/BruteforceGuard.php tests/Feature/BruteforceGuardExemptTest.php +git commit -m "feat(auth): BruteforceGuard with inet_pton CIDR exemptions" +``` + +--- + +### Task 3: `BruteforceGuard` — record, ban, isBanned, cache + +**Files:** Test `tests/Feature/BruteforceGuardBanTest.php` (the service already exists from Task 2) + +- [ ] **Step 1: Failing test** + +```php +guard(); + Setting::put('bruteforce_maxretry', '3'); + + $g->record('203.0.113.5', 'login'); + $g->record('203.0.113.5', 'login'); + $this->assertFalse($g->isBanned('203.0.113.5')); + + $g->record('203.0.113.5', 'login'); // 3rd → ban + $this->assertTrue($g->isBanned('203.0.113.5')); + $this->assertDatabaseHas('banned_ips', ['ip' => '203.0.113.5', 'reason' => 'login']); + $this->assertDatabaseHas('audit_events', ['action' => 'auth.ip_banned', 'ip' => '203.0.113.5']); + } + + public function test_exempt_ip_is_never_banned(): void + { + $g = $this->guard(); + Setting::put('bruteforce_maxretry', '1'); + for ($i = 0; $i < 5; $i++) { + $g->record('192.168.1.50', 'login'); // private = default whitelist + } + $this->assertFalse($g->isBanned('192.168.1.50')); + $this->assertDatabaseCount('banned_ips', 0); + } + + public function test_disabled_records_nothing(): void + { + Setting::put('bruteforce_enabled', '0'); + Setting::put('bruteforce_maxretry', '1'); + $this->guard()->record('203.0.113.9', 'login'); + $this->assertDatabaseCount('banned_ips', 0); + } + + public function test_unban_clears_row_and_cache(): void + { + $g = $this->guard(); + BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]); + $this->assertTrue($g->isBanned('203.0.113.5')); // populates cache + $g->unban('203.0.113.5'); + $this->assertFalse($g->isBanned('203.0.113.5')); // no stale positive + $this->assertDatabaseCount('banned_ips', 0); + } + + public function test_expired_ban_is_not_active(): void + { + BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->subMinute(), 'attempts' => 10]); + $this->assertFalse($this->guard()->isBanned('203.0.113.5')); + } +} +``` + +- [ ] **Step 2: Run → passes** (the Task 2 implementation already covers this; if any assertion fails, fix `BruteforceGuard` and re-run — do not commit red). + +- [ ] **Step 3: Commit** + +```bash +git add tests/Feature/BruteforceGuardBanTest.php +git commit -m "test(auth): BruteforceGuard record/ban/unban/cache" +``` + +--- + +### Task 4: `ValidIpOrCidr` rule + +**Files:** Create `app/Rules/ValidIpOrCidr.php`; Test `tests/Feature/ValidIpOrCidrTest.php` + +- [ ] **Step 1: Failing test** + +```php + $value], ['v' => [new ValidIpOrCidr]])->passes(); + } + + public function test_accepts_ips_and_cidrs(): void + { + $this->assertTrue($this->passes('203.0.113.4')); + $this->assertTrue($this->passes('10.0.0.0/8')); + $this->assertTrue($this->passes('::1')); + $this->assertTrue($this->passes('2001:db8::/32')); + } + + public function test_rejects_junk(): void + { + $this->assertFalse($this->passes('nope')); + $this->assertFalse($this->passes('10.0.0.0/99')); + $this->assertFalse($this->passes('1.2.3.4/')); + } +} +``` + +- [ ] **Step 2: Run → fails.** + +- [ ] **Step 3: Implement** (`app/Rules/ValidIpOrCidr.php`) + +```php + $v])); + + return; + } + + [$ip, $mask] = explode('/', $v, 2); + $max = str_contains($ip, ':') ? 128 : 32; + if (filter_var($ip, FILTER_VALIDATE_IP) === false || ! ctype_digit($mask) || (int) $mask < 0 || (int) $mask > $max) { + $fail(__('settings.lp_whitelist_invalid', ['value' => $v])); + } + } +} +``` + +- [ ] **Step 4: Add the `lp_whitelist_invalid` key** to both `lang/de/settings.php` and `lang/en/settings.php` (the test resolves `__()` — a missing key returns the key string, which still "passes" the boolean test, but add it now so the message is real): + - de: `'lp_whitelist_invalid' => ':value ist keine gültige IP/CIDR.',` + - en: `'lp_whitelist_invalid' => ':value is not a valid IP/CIDR.',` + +- [ ] **Step 5: Run → passes. Pint + commit** + +```bash +docker compose exec -T app ./vendor/bin/pint app/Rules/ValidIpOrCidr.php tests/Feature/ValidIpOrCidrTest.php +git add app/Rules/ValidIpOrCidr.php tests/Feature/ValidIpOrCidrTest.php lang/de/settings.php lang/en/settings.php +git commit -m "feat(auth): ValidIpOrCidr validation rule" +``` + +--- + +### Task 5: `BlockBannedIp` middleware + 403 page + +**Files:** Create `app/Http/Middleware/BlockBannedIp.php`, `resources/views/errors/blocked.blade.php`; Modify `bootstrap/app.php`, `lang/de/errors.php`, `lang/en/errors.php`; Test `tests/Feature/BlockBannedIpMiddlewareTest.php` + +- [ ] **Step 1: Failing test** + +```php + $ip, 'banned_until' => now()->addHour(), 'attempts' => 10]); + } + + public function test_guest_from_banned_ip_is_blocked_with_403(): void + { + $this->banned(); + $this->get('/login', ['REMOTE_ADDR' => '203.0.113.5']) + ->assertStatus(403) + ->assertSee(__('errors.blocked_title')); + } + + public function test_guest_from_clean_ip_passes(): void + { + $this->banned(); + $this->get('/login', ['REMOTE_ADDR' => '198.51.100.9'])->assertStatus(200); + } + + public function test_authenticated_user_from_banned_ip_passes(): void + { + $this->banned(); + $user = User::factory()->create(['must_change_password' => false]); + $this->actingAs($user) + ->withServerVariables(['REMOTE_ADDR' => '203.0.113.5']) + ->get('/') + ->assertSuccessful(); + } + + public function test_feature_disabled_lets_banned_ip_through(): void + { + Setting::put('bruteforce_enabled', '0'); + $this->banned(); + $this->get('/login', ['REMOTE_ADDR' => '203.0.113.5'])->assertStatus(200); + } +} +``` + +> Note: `$this->get($uri, $headers)` sets request headers; for the client IP in tests use `withServerVariables(['REMOTE_ADDR' => ...])` (shown in the authed test) — adjust the guest tests to the same form if `REMOTE_ADDR` via the headers arg does not drive `request()->ip()` in this Laravel version. Verify at Step 4 and use whichever reaches `request()->ip()`. + +- [ ] **Step 2: Run → fails** (middleware not registered). + +- [ ] **Step 3: Middleware** (`app/Http/Middleware/BlockBannedIp.php`) + +```php +ip() is correct via the global trustProxies (prod). + */ +class BlockBannedIp +{ + public function __construct(private BruteforceGuard $guard) {} + + public function handle(Request $request, Closure $next): Response + { + if ($this->guard->enabled() && Auth::guest() && $this->guard->isBanned((string) $request->ip())) { + return response()->view('errors.blocked', [], 403); + } + + return $next($request); + } +} +``` + +- [ ] **Step 4: Register** — in `bootstrap/app.php`, add `use App\Http\Middleware\BlockBannedIp;` and append it to the web group (after `AuthenticateSession`): + +```php + $middleware->web( + prepend: [PanelScheme::class], + append: [SetLocale::class, SecurityHeaders::class, AuthenticateSession::class, BlockBannedIp::class], + ); +``` + +- [ ] **Step 5: 403 view** (`resources/views/errors/blocked.blade.php`) + +```blade +@extends('errors.layout', ['title' => __('errors.blocked_title')]) +@section('code', '403') +@section('heading', __('errors.blocked_title')) +@section('message', __('errors.blocked_body')) +``` + +- [ ] **Step 6: errors i18n** — add to `lang/de/errors.php` and `lang/en/errors.php`: + - de: `'blocked_title' => 'Zugriff vorübergehend gesperrt',` / `'blocked_body' => 'Zu viele fehlgeschlagene Anmeldeversuche aus deinem Netzwerk. Bitte später erneut versuchen.',` + - en: `'blocked_title' => 'Access temporarily blocked',` / `'blocked_body' => 'Too many failed sign-in attempts from your network. Please try again later.',` + +- [ ] **Step 7: Run → passes** (fix the IP-injection form per the Step 1 note if needed). Pint + commit + +```bash +docker compose exec -T app ./vendor/bin/pint app/Http/Middleware/BlockBannedIp.php bootstrap/app.php tests/Feature/BlockBannedIpMiddlewareTest.php +git add app/Http/Middleware/BlockBannedIp.php bootstrap/app.php resources/views/errors/blocked.blade.php lang/de/errors.php lang/en/errors.php tests/Feature/BlockBannedIpMiddlewareTest.php +git commit -m "feat(auth): guests-only BlockBannedIp middleware + 403 page" +``` + +--- + +### Task 6: Failure hooks — Login + 2FA + +**Files:** Modify `app/Livewire/Auth/Login.php`, `app/Livewire/Concerns/CompletesTwoFactorChallenge.php`, `app/Livewire/Auth/TwoFactorChallenge.php`, `app/Livewire/Auth/TwoFactorBackup.php`; Test `tests/Feature/BruteforceHooksTest.php` + +- [ ] **Step 1: Failing test** + +```php +create(['email' => 'admin@clusev.local', 'password' => Hash::make('correct-horse')]); + + for ($i = 0; $i < 3; $i++) { + Livewire::withServerVariables(['REMOTE_ADDR' => '203.0.113.5']) + ->test(Login::class)->set('email', 'admin@clusev.local')->set('password', 'wrong')->call('authenticate'); + } + + $this->assertDatabaseHas('banned_ips', ['ip' => '203.0.113.5', 'reason' => 'login']); + $this->assertDatabaseHas('audit_events', ['action' => 'auth.login_failed']); + } + + public function test_failed_2fa_codes_ban_the_ip(): void + { + $user = User::factory()->create(['two_factor_secret' => (new Google2FA)->generateSecretKey(), 'two_factor_confirmed_at' => now()]); + + for ($i = 0; $i < 3; $i++) { + session()->put('2fa.user', $user->id); + Livewire::withServerVariables(['REMOTE_ADDR' => '203.0.113.6']) + ->test(TwoFactorChallenge::class)->set('code', '000000')->call('verify'); + } + + $this->assertDatabaseHas('banned_ips', ['ip' => '203.0.113.6', 'reason' => '2fa']); + } +} +``` + +> If `Livewire::withServerVariables` is unavailable, set the IP via the test request the same way Task 5 settled on; the 2FA per-(user+IP) rate-limit (5/60s) is above 3, so it won't trip first. + +- [ ] **Step 2: Run → fails** (no ban created — hooks absent). + +- [ ] **Step 3: Login hook** — in `app/Livewire/Auth/Login.php`, add imports `use App\Models\AuditEvent;` and `use App\Services\BruteforceGuard;`, then inside the `if (! $user || ! $validPassword) {` block, **before** the `throw`, add (after the three `RateLimiter::hit` lines): + +```php + app(BruteforceGuard::class)->record(request()->ip(), 'login'); + AuditEvent::create([ + 'user_id' => null, + 'actor' => 'system', + 'action' => 'auth.login_failed', + 'target' => $this->email, + 'ip' => request()->ip(), + ]); +``` + +- [ ] **Step 4: 2FA hook** — in `app/Livewire/Concerns/CompletesTwoFactorChallenge.php`, add imports `use App\Models\AuditEvent;` and `use App\Services\BruteforceGuard;`, then add a method: + +```php + /** A failed 2FA attempt: throttle (Layer 1) + feed the persistent ban (Layer 2) + audit. */ + protected function recordFailedAttempt(string $reason): void + { + $this->hitRateLimit(); + $ip = request()->ip(); + app(BruteforceGuard::class)->record($ip, $reason); + AuditEvent::create([ + 'user_id' => null, + 'actor' => 'system', + 'action' => 'auth.2fa_failed', + 'target' => (string) session('2fa.user'), + 'ip' => $ip, + ]); + } +``` + + Then replace the three `$this->hitRateLimit();` call sites with `$this->recordFailedAttempt('2fa');`: + - `app/Livewire/Auth/TwoFactorChallenge.php` `verify()` (the line inside `if (! $valid)`) + - `app/Livewire/Auth/TwoFactorChallenge.php` `verifyWebauthn()` (the line inside the failure `if`) + - `app/Livewire/Auth/TwoFactorBackup.php` `verify()` (the line inside `if (! $user->useRecoveryCode(...))`) + +- [ ] **Step 5: Run → passes**, then the full auth suite stays green: + +```bash +docker compose exec -T app php artisan test --filter='Bruteforce|TwoFactor|Challenge|Webauthn|BruteForce|Login' +``` + +- [ ] **Step 6: Pint + commit** + +```bash +docker compose exec -T app ./vendor/bin/pint app/Livewire/Auth/Login.php app/Livewire/Concerns/CompletesTwoFactorChallenge.php app/Livewire/Auth/TwoFactorChallenge.php app/Livewire/Auth/TwoFactorBackup.php tests/Feature/BruteforceHooksTest.php +git add app/Livewire/Auth/Login.php app/Livewire/Concerns/CompletesTwoFactorChallenge.php app/Livewire/Auth/TwoFactorChallenge.php app/Livewire/Auth/TwoFactorBackup.php tests/Feature/BruteforceHooksTest.php +git commit -m "feat(auth): feed BruteforceGuard from login + 2FA failures, audit them" +``` + +--- + +### Task 7: `clusev:unban` CLI + host wrapper + +**Files:** Create `app/Console/Commands/UnbanIp.php`; Modify `docker/clusev/clusev`; Test `tests/Feature/UnbanCommandTest.php` + +- [ ] **Step 1: Failing test** + +```php + '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]); + $this->artisan('clusev:unban', ['ip' => '203.0.113.5'])->assertSuccessful(); + $this->assertDatabaseCount('banned_ips', 0); + } + + public function test_unban_all(): void + { + BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]); + BannedIp::create(['ip' => '203.0.113.6', 'banned_until' => now()->addHour(), 'attempts' => 10]); + $this->artisan('clusev:unban', ['--all' => true])->assertSuccessful(); + $this->assertDatabaseCount('banned_ips', 0); + } + + public function test_unban_without_args_fails(): void + { + $this->artisan('clusev:unban')->assertFailed(); + } +} +``` + +- [ ] **Step 2: Run → fails.** + +- [ ] **Step 3: Command** (`app/Console/Commands/UnbanIp.php`) + +```php +option('all')) { + $count = BannedIp::query()->count(); + foreach (BannedIp::query()->pluck('ip') as $ip) { + Cache::forget('bruteforce:banned:'.$ip); + } + BannedIp::query()->delete(); + $this->info("{$count} Bann/Bänne entfernt."); + + return self::SUCCESS; + } + + $ip = (string) $this->argument('ip'); + if ($ip === '') { + $this->error('Bitte eine IP angeben oder --all verwenden.'); + + return self::FAILURE; + } + + $guard->unban($ip); + $this->info("Bann für {$ip} entfernt (falls vorhanden)."); + + return self::SUCCESS; + } +} +``` + +- [ ] **Step 4: Host wrapper** — in `docker/clusev/clusev`, add a usage line under the `reset-admin` line (around line 21): ` clusev unban Gebannte IP entsperren (--all für alle)` and add a case after the `reset-admin)` case: + +```bash + unban) compose exec app php artisan clusev:unban "$@" ;; +``` + +- [ ] **Step 5: Run → passes. Pint + commit** + +```bash +docker compose exec -T app ./vendor/bin/pint app/Console/Commands/UnbanIp.php tests/Feature/UnbanCommandTest.php +git add app/Console/Commands/UnbanIp.php docker/clusev/clusev tests/Feature/UnbanCommandTest.php +git commit -m "feat(auth): clusev:unban CLI + host wrapper escape hatch" +``` + +--- + +### Task 8: Settings tab "Anmeldeschutz" + +**Files:** Modify `app/Support/Confirm/ConfirmToken.php`, `resources/views/livewire/settings/index.blade.php`, `lang/de/settings.php`, `lang/en/settings.php`; Create `app/Livewire/Settings/LoginProtection.php`, `resources/views/livewire/settings/login-protection.blade.php`; Test `tests/Feature/Settings/LoginProtectionTabTest.php` + +- [ ] **Step 1: Allowlist the confirm events** — in `app/Support/Confirm/ConfirmToken.php`, add two entries to the `ACTIONS` const array: + +```php + 'banCleared', + 'bansClearedAll', +``` + +- [ ] **Step 2: Failing test** + +```php +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]); + + $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(); + Livewire::actingAs($user)->withServerVariables(['REMOTE_ADDR' => '198.51.100.7']) + ->test(LoginProtection::class)->call('whitelistMyIp') + ->assertSet('whitelist', fn ($w) => str_contains($w, '198.51.100.7')); + } +} +``` + +- [ ] **Step 3: Run → fails.** + +- [ ] **Step 4: Component** (`app/Livewire/Settings/LoginProtection.php`) + +```php +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()), + ]); + } +} +``` + +- [ ] **Step 5: View** (`resources/views/livewire/settings/login-protection.blade.php`) — mirror the profile form + sessions table patterns: + +```blade +@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') }} +

+
+``` + +- [ ] **Step 6: Register the tab** — in `resources/views/livewire/settings/index.blade.php`, add to the `$tabs` array right after the `security` entry: + +```php + ['key' => 'login-protection', 'label' => __('settings.tab_login_protection'), 'icon' => 'shield'], +``` + + and in the content switch, after the `security` arm and before the closing `@endif`, add the arm + a safe default: + +```blade + @elseif ($tab === 'login-protection') + @elseif ($tab === 'users') + @elseif ($tab === 'sessions') + @elseif ($tab === 'email') + @else + @endif +``` + +- [ ] **Step 7: settings i18n** — add to BOTH `lang/de/settings.php` and `lang/en/settings.php` (identical keys; DE values shown — provide the EN equivalents in the same terse register): + +```php + '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.', +``` + + (`lp_whitelist_invalid` was already added in Task 4.) + +- [ ] **Step 8: Run → passes** + +```bash +docker compose exec -T app php artisan test --filter=LoginProtectionTabTest +``` + +- [ ] **Step 9: Pint + commit** + +```bash +docker compose exec -T app ./vendor/bin/pint app/Support/Confirm/ConfirmToken.php app/Livewire/Settings/LoginProtection.php tests/Feature/Settings/LoginProtectionTabTest.php +git add app/Support/Confirm/ConfirmToken.php app/Livewire/Settings/LoginProtection.php resources/views/livewire/settings/login-protection.blade.php resources/views/livewire/settings/index.blade.php lang/de/settings.php lang/en/settings.php tests/Feature/Settings/LoginProtectionTabTest.php +git commit -m "feat(auth): Anmeldeschutz settings tab + unban confirm flow" +``` + +--- + +### Task 9: Full verification (R12 + R15) + +- [ ] **Step 1: Full suite** + +```bash +docker compose exec -T app php artisan test +``` +Expected: PASS, 0 failures. + +- [ ] **Step 2: Build + view:clear** (Blade changed) + +```bash +docker compose exec -T app php artisan view:clear +docker compose exec -T app npm run build +``` + +- [ ] **Step 3: R12 browser-verify** (DE+EN), via the temp-password puppeteer flow (admin@clusev.local): log in, open Settings → **Anmeldeschutz** tab — HTTP 200, zero console/network errors, DOM leak scan scoped to the tab (no `{{ }}`/`@`/`settings.lp_*` literals), 3 breakpoints (375/768/1280), touch targets ≥44px. Confirm the config form, the empty/active bans table, and the current-IP/self-whitelist row render. The 403 `errors.blocked` page is covered by the feature test (it returns 403, not 200) — not via the R12 200-path. + +- [ ] **Step 4: R15 Codex** — run `/codex:review` over the branch; fix until no errors / no security issues. + +- [ ] **Step 5: Final Pint** + +```bash +docker compose exec -T app ./vendor/bin/pint --dirty +``` + +--- + +## Self-review notes + +- **Spec coverage:** §2 BannedIp → Task 1; §3 Guard (config/record/ban/exempt/CIDR) → Tasks 2–3; §4 middleware (guests-only, appended — correcting the spec's "prepend") + 403 → Task 5; §5 hooks + audit → Task 6; §6 tab (+ ConfirmToken allowlist, self-whitelist, whitelist-purge, R5 modals) → Task 8; §7 CLI escape → Task 7; §8 i18n split across Tasks 4/5/8. §0 trust model = no code (documented). +- **Casting:** every `Setting::get` is cast (`=== '1'` / `(int)`); every `Setting::put` stringifies. **Units:** findtime/bantime are minutes; `record()` multiplies findtime ×60 for the RateLimiter decay. +- **Cache:** `bruteforce:banned:{canonical}` (30s), busted on ban + unban; test covers the no-stale-positive case. +- **R5:** both unban actions use `modals.confirm-action` + `ConfirmToken` (allowlisted `banCleared` / `bansClearedAll`). +- **Type consistency:** service methods (`enabled/maxretry/findtime/bantime/whitelist/isExempt/record/isBanned/unban/matchesCidr`) are referenced identically across middleware, hooks, command, and tab. +- **Out of scope (per spec):** host-fail2ban logfile; remote Fail2banService; trustProxies changes. diff --git a/docs/superpowers/specs/2026-06-20-control-plane-bruteforce-ban-design.md b/docs/superpowers/specs/2026-06-20-control-plane-bruteforce-ban-design.md index 58747bc..4a83468 100644 --- a/docs/superpowers/specs/2026-06-20-control-plane-bruteforce-ban-design.md +++ b/docs/superpowers/specs/2026-06-20-control-plane-bruteforce-ban-design.md @@ -101,9 +101,12 @@ New model `App\Models\BannedIp` + migration `banned_ips`: ## 4. `BlockBannedIp` middleware — enforcement (guests only) -New `App\Http\Middleware\BlockBannedIp`, **prepended to the `web` group BEFORE `PanelScheme`** in -[`bootstrap/app.php`](../../../bootstrap/app.php) (prepend array = `[BlockBannedIp::class, PanelScheme::class]`). -trustProxies has already resolved the real IP by then (§0). +New `App\Http\Middleware\BlockBannedIp`, **appended to the `web` group** (last, after +`AuthenticateSession`) in [`bootstrap/app.php`](../../../bootstrap/app.php). It must be **appended, not +prepended**: the guests-only check needs `Auth::guest()`, which requires the session middleware +(`StartSession`) to have already run — a prepended middleware runs before it and would see every +request as a guest (blocking authenticated operators too). `request()->ip()` is still correct because +`trustProxies` is a global middleware that runs before the whole web group (§0). - If the feature is disabled → pass (one cached `Setting` read; no DB hit). - **Block guests only:** if `Auth::guest()` **and** `BruteforceGuard::isBanned(request()->ip())` → @@ -190,7 +193,7 @@ New: `app/Models/BannedIp.php`, `database/migrations/xxxx_create_banned_ips_tabl `app/Services/BruteforceGuard.php`, `app/Http/Middleware/BlockBannedIp.php`, `app/Rules/ValidIpOrCidr.php`, `app/Console/Commands/UnbanIp.php` (`clusev:unban`), `app/Livewire/Settings/LoginProtection.php` + `resources/views/livewire/settings/login-protection.blade.php`, `resources/views/errors/blocked.blade.php`, -the feature tests. Modify: `bootstrap/app.php` (prepend `BlockBannedIp` before `PanelScheme`), +the feature tests. Modify: `bootstrap/app.php` (append `BlockBannedIp` to the web group), `app/Livewire/Auth/Login.php` + `app/Livewire/Concerns/CompletesTwoFactorChallenge.php` (record + audit hooks), `resources/views/livewire/settings/index.blade.php` (new tab + `@else` default), `lang/de/settings.php`, `lang/en/settings.php`, `lang/de/errors.php`, `lang/en/errors.php`, `docker/clusev/clusev` (add `unban`).