clusev/docs/superpowers/plans/2026-06-20-control-plane-br...

51 KiB
Raw Blame History

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

namespace Tests\Feature;

use App\Models\BannedIp;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class BruteforceBannedIpTest extends TestCase
{
    use RefreshDatabase;

    public function test_active_scope_excludes_expired_bans(): void
    {
        BannedIp::create(['ip' => '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

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('banned_ips', function (Blueprint $table) {
            $table->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

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

class BannedIp extends Model
{
    protected $guarded = [];

    protected $casts = ['banned_until' => 'datetime'];

    /** Non-expired bans only. */
    public function scopeActive(Builder $query): Builder
    {
        return $query->where('banned_until', '>', now());
    }
}
  • Step 5: Migrate + run → passes
docker compose exec -T app php artisan migrate
docker compose exec -T app php artisan test --filter=BruteforceBannedIpTest
  • Step 6: Pint + commit
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

namespace Tests\Feature;

use App\Models\Setting;
use App\Services\BruteforceGuard;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class BruteforceGuardExemptTest extends TestCase
{
    use RefreshDatabase;

    private function guard(): BruteforceGuard
    {
        return app(BruteforceGuard::class);
    }

    public function test_loopback_and_ipv6_loopback_are_exempt(): void
    {
        $g = $this->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

namespace App\Services;

use App\Models\AuditEvent;
use App\Models\BannedIp;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;

/**
 * Application-level brute-force IP ban for the CONTROL PLANE's own login. Independent of
 * the remote-fleet Fail2banService: this blocks at the HTTP layer (BlockBannedIp middleware),
 * not via SSH firewall rules. Failures are counted in cache; bans are persisted in banned_ips.
 */
class BruteforceGuard
{
    /** Always exempt — hard, non-configurable (prevents loopback/health self-lockout). */
    private const LOOPBACK = ['127.0.0.0/8', '::1', '::ffff:127.0.0.1'];

    /** Default operator whitelist: private ranges (covers LAN/VPN + the Docker/proxy net). */
    private const DEFAULT_WHITELIST = "10.0.0.0/8\n172.16.0.0/12\n192.168.0.0/16";

    private const CHECK_TTL = 30;

    public function enabled(): bool
    {
        return Setting::get('bruteforce_enabled', '1') === '1';
    }

    public function maxretry(): int
    {
        return max(1, (int) Setting::get('bruteforce_maxretry', '10'));
    }

    /** findtime in MINUTES. */
    public function findtime(): int
    {
        return max(1, (int) Setting::get('bruteforce_findtime', '10'));
    }

    /** bantime in MINUTES. */
    public function bantime(): int
    {
        return max(1, (int) Setting::get('bruteforce_bantime', '60'));
    }

    /** @return string[] */
    public function whitelist(): array
    {
        $raw = (string) Setting::get('bruteforce_whitelist', self::DEFAULT_WHITELIST);

        return array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $raw) ?: [])));
    }

    public function isExempt(string $ip): bool
    {
        if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
            return true; // unknown/malformed IP → never count or ban
        }

        foreach (array_merge(self::LOOPBACK, $this->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

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

namespace Tests\Feature;

use App\Models\BannedIp;
use App\Models\Setting;
use App\Services\BruteforceGuard;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;

class BruteforceGuardBanTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        Cache::flush();
    }

    private function guard(): BruteforceGuard
    {
        return app(BruteforceGuard::class);
    }

    public function test_records_until_threshold_then_bans(): void
    {
        $g = $this->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

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

namespace Tests\Feature;

use App\Rules\ValidIpOrCidr;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;

class ValidIpOrCidrTest extends TestCase
{
    private function passes(string $value): bool
    {
        return Validator::make(['v' => $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

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class ValidIpOrCidr implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $v = is_string($value) ? trim($value) : '';

        if (filter_var($v, FILTER_VALIDATE_IP) !== false) {
            return;
        }

        if (! str_contains($v, '/')) {
            $fail(__('settings.lp_whitelist_invalid', ['value' => $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

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

namespace Tests\Feature;

use App\Models\BannedIp;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;

class BlockBannedIpMiddlewareTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        Cache::flush();
    }

    private function banned(string $ip = '203.0.113.5'): void
    {
        BannedIp::create(['ip' => $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

namespace App\Http\Middleware;

use App\Services\BruteforceGuard;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;

/**
 * Hard-blocks GUEST requests from a banned IP (403). Authenticated requests pass, so an
 * operator already logged in is never cut off mid-session and can unban their own IP from
 * the Anmeldeschutz tab. Appended to the web group so Auth::guest() resolves (the session
 * middleware has run). request()->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):
        $middleware->web(
            prepend: [PanelScheme::class],
            append: [SetLocale::class, SecurityHeaders::class, AuthenticateSession::class, BlockBannedIp::class],
        );
  • Step 5: 403 view (resources/views/errors/blocked.blade.php)
@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

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

namespace Tests\Feature;

use App\Livewire\Auth\Login;
use App\Livewire\Auth\TwoFactorChallenge;
use App\Models\BannedIp;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Livewire\Livewire;
use PragmaRX\Google2FAQRCode\Google2FA;
use Tests\TestCase;

class BruteforceHooksTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        Cache::flush();
        Setting::put('bruteforce_maxretry', '3');
        // The test client IP is 127.0.0.1 (exempt by default) — point the whitelist elsewhere.
        Setting::put('bruteforce_whitelist', '198.51.100.0/24');
    }

    public function test_failed_logins_ban_the_ip_at_threshold(): void
    {
        User::factory()->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):

            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:
    /** 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:

docker compose exec -T app php artisan test --filter='Bruteforce|TwoFactor|Challenge|Webauthn|BruteForce|Login'
  • Step 6: Pint + commit
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

namespace Tests\Feature;

use App\Models\BannedIp;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class UnbanCommandTest extends TestCase
{
    use RefreshDatabase;

    public function test_unban_single_ip(): void
    {
        BannedIp::create(['ip' => '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

namespace App\Console\Commands;

use App\Models\BannedIp;
use App\Services\BruteforceGuard;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;

class UnbanIp extends Command
{
    protected $signature = 'clusev:unban {ip? : Zu entsperrende IP-Adresse} {--all : Alle aktiven Bänne entfernen}';

    protected $description = 'Entfernt App-Level-IP-Bänne des Anmeldeschutzes per Shell (Aussperr-Rettung)';

    public function handle(BruteforceGuard $guard): int
    {
        if ($this->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 <ip> Gebannte IP entsperren (--all für alle) and add a case after the reset-admin) case:
  unban)          compose exec app php artisan clusev:unban "$@" ;;
  • Step 5: Run → passes. Pint + commit
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:
        'banCleared',
        'bansClearedAll',
  • Step 2: Failing test
<?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]);

        $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

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()),
        ]);
    }
}
  • Step 5: View (resources/views/livewire/settings/login-protection.blade.php) — mirror the profile form + sessions table patterns:
@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>
  • Step 6: Register the tab — in resources/views/livewire/settings/index.blade.php, add to the $tabs array right after the security entry:
        ['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:

            @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
  • 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):
    '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.',

(lp_whitelist_invalid was already added in Task 4.)

  • Step 8: Run → passes
docker compose exec -T app php artisan test --filter=LoginProtectionTabTest
  • Step 9: Pint + commit
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
docker compose exec -T app php artisan test

Expected: PASS, 0 failures.

  • Step 2: Build + view:clear (Blade changed)
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

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 23; §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.