# Control-plane brute-force IP ban (Anmeldeschutz) — Design

**Date:** 2026-06-20 · **Branch:** `feat/v1-foundation` · **Status:** approved

Adds a **persistent, app-level IP ban** that protects Clusev's **own** login (the control-plane),
fail2ban-style: too many failed auth attempts from one IP → that IP is hard-blocked for unauthenticated
requests for a while, configurable from a new **"Anmeldeschutz"** settings tab that also lists and
clears active bans.

This is **distinct** from the existing `Fail2banService` (`app/Services/Fail2banService.php`), which
manages fail2ban on **remote fleet servers** over SSH — that stays untouched and shares no state,
config, or audit events with this feature. A real host fail2ban daemon is not an option here: the app
runs in a container with no host firewall / iptables / systemd access, so it can never write host
firewall rules, and a host daemon's ban list could not be shown or managed from the panel. The ban
therefore lives **in the application**: counted in cache, persisted in the DB, enforced in HTTP
middleware.

Chosen defaults (operator-tunable in the tab): **enabled**, **10 failures / 10 min → 60 min ban**.

## 0. IP trust model — the security assumption everything rests on

The ban is only as correct as the client IP it sees. In prod, [`bootstrap/app.php`](../../../bootstrap/app.php)
calls `trustProxies(at: '*')` **only when `APP_ENV=production`**; the app container is internal-only
(`expose: 80`, no host port — `docker-compose.prod.yml`), so its **sole** direct peer is Caddy.
Caddy's `trusted_proxies` (the `TRUSTED_PROXY_CIDR` env, default `127.0.0.1/32` — `docker/caddy/Caddyfile`)
means Caddy does **not** trust a client's forged `X-Forwarded-For` and rewrites it to the real peer.
So `request()->ip()` = the real client, and XFF spoofing is prevented **as long as Caddy is the single
front proxy and its trusted_proxies is correct (the default)**.

- **Stated assumption (put in the tab help + the `clusev:unban` docs):** Caddy must be the only
  intermediary. If the operator puts a second, untrusted proxy in front of Caddy that blindly forwards
  XFF, the app can be made to ban a forged/victim IP. The whitelist + the host CLI escape (§7) bound
  the damage.
- **Dev:** `trustProxies` is not called, so `request()->ip()` is the Docker gateway, not a real client.
  This is a **production feature**; dev/unit tests exercise the logic by injecting the IP (mock
  `request()->ip()` / `Request::create(...)`), not by relying on real forwarding.

---

## 1. Two layers, clearly separated

- **Layer 1 — existing soft throttle (unchanged).** The `RateLimiter` buckets in
  [`Login`](../../../app/Livewire/Auth/Login.php) and
  [`CompletesTwoFactorChallenge`](../../../app/Livewire/Concerns/CompletesTwoFactorChallenge.php)
  (5/60 s etc.) stay exactly as they are — a short, self-expiring, per-(email+IP) friction layer.
- **Layer 2 — new persistent IP ban (this spec).** A longer-horizon escalation: count failures per IP
  within `findtime`; at `maxretry` create a **persisted** ban for `bantime` that blocks **unauthenticated**
  requests from that IP via early middleware (see §4 for why guests-only). Survives restarts; enumerable
  and clearable from the panel + a host CLI.

## 2. Data model — `BannedIp`

New model `App\Models\BannedIp` + migration `banned_ips`:

| column | type | note |
|---|---|---|
| `id` | bigint PK | |
| `ip` | string(45), **unique, indexed** | stored in **canonical form** (`inet_ntop(inet_pton($ip))`) |
| `banned_until` | timestamp | ban auto-expires at this instant (app timezone via `now()`) |
| `reason` | string, nullable | `login` or `2fa` |
| `attempts` | unsigned int | failures that triggered the ban (for the UI) |
| timestamps | | `created_at` = banned-at |

"Active ban" = row where `banned_until > now()`. Ban **history** is the existing `AuditEvent` stream
(`auth.ip_banned` / `auth.ip_unbanned`), not a second table.

## 3. `BruteforceGuard` service — record, ban, exempt

`App\Services\BruteforceGuard` owns all decision logic.

- **`config()`** reads the `Setting` keys with casts (Setting stores **strings only** — see
  [`Setting`](../../../app/Models/Setting.php)): `enabled = Setting::get('bruteforce_enabled', '1') === '1'`,
  `maxretry = (int) Setting::get('bruteforce_maxretry', '10')`, `findtime = (int) …('bruteforce_findtime', '10')`,
  `bantime = (int) …('bruteforce_bantime', '60')` (both in **minutes**), `whitelist` = the parsed CIDR list.

- **`record(?string $ip, string $reason): void`** — the failure recorder:
  1. Return immediately if the feature is disabled, `$ip` is empty / not `filter_var(...FILTER_VALIDATE_IP)`,
     or `isExempt($ip)` is true (no counting of exempt/loopback/whitelisted IPs).
  2. Register a hit on a rolling counter and read the count:
     `RateLimiter::hit('bruteforce:'.md5($ip), $findtime * 60); $hits = RateLimiter::attempts('bruteforce:'.md5($ip));`
     (`hit()` returns void in this Laravel version — the count comes from `attempts()`; `findtime`
     is minutes → ×60 for the second/decay arg, matching the existing RateLimiter usage).
  3. If `$hits >= $maxretry`: **atomically** upsert the ban (race-safe — two concurrent requests at the
     threshold must not collide on the unique `ip` index):
     `BannedIp::upsert([['ip' => $canonical, 'banned_until' => now()->addMinutes($bantime), 'reason' => $reason, 'attempts' => $hits]], ['ip'], ['banned_until', 'reason', 'attempts'])`,
     then `RateLimiter::clear(...)` the counter, `Cache::forget('bruteforce:banned:'.$canonical)`,
     and write `AuditEvent` `auth.ip_banned` (payload in §5).

- **`isBanned(string $ip): bool`** — `false` if `isExempt($ip)` (exempt short-circuits, so whitelisting
  an IP unblocks it **immediately** even while a row lingers); else a cached lookup of an active,
  non-expired `BannedIp` for the canonical IP. Cache: `Cache::remember('bruteforce:banned:'.$canonical, 30, fn () => BannedIp::where('ip', $canonical)->where('banned_until', '>', now())->exists())`
  — a 30 s per-IP boolean, busted (`Cache::forget`) on every ban and unban so a banned IP is visible
  ≤30 s after creation and an unbanned IP is free immediately.

- **`isExempt(string $ip): bool`** — **hard, non-negotiable safety.** Always true for loopback
  (`127.0.0.0/8`, `::1`), the configured trusted-proxy CIDR, and any whitelist entry. Matching uses an
  **`inet_pton`-based CIDR helper** (`matchesCidr(string $ip, string $cidr): bool`) that normalises both
  sides to packed bytes and compares the network prefix — IPv4, IPv6, and IPv4-mapped-IPv6
  (`::ffff:127.0.0.1`) all handled, mirroring `Fail2banService`'s `inet_pton` comparison so spellings
  like `::1` vs `0:0:0:0:0:0:0:1` can't bypass it.

## 4. `BlockBannedIp` middleware — enforcement (guests only)

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())` →
  return a minimal **403** and **do not call `$next()`** (no downstream middleware/route runs). An
  **authenticated** request always passes — an operator already logged in is past the login brute-force
  surface, is never cut off mid-session, and can reach the Anmeldeschutz tab to unban their own IP even
  while it is banned. The login + 2FA pages are guest requests, so the brute-force surface itself is
  still fully covered.
- **403 response:** a dedicated, tiny `errors.blocked` view (no app shell, no Livewire, no JS),
  rendered in the saved locale. Generic copy only — "Zugriff vorübergehend gesperrt … später erneut
  versuchen" — it must **not** reveal the exact expiry time, the reason, or whether the IP was flagged
  (no info leak / no confirmation to an attacker).

## 5. Hooks — feed the guard

Centralise one call so all failure sites share it; each runs **after** the existing
`RateLimiter::hit`/`assertNotRateLimited` (additive, never changes the throttle):

- [`Login::authenticate()`](../../../app/Livewire/Auth/Login.php) on a wrong password →
  `app(BruteforceGuard::class)->record(request()->ip(), 'login')` + `AuditEvent`:
  `actor='system'`, `action='auth.login_failed'`, `target=$email`, `ip=request()->ip()` (failed logins
  are not audited today — this closes that gap).
- The 2FA failure path in `CompletesTwoFactorChallenge` (the three `hitRateLimit()` sites:
  `TwoFactorChallenge::verify`/`verifyWebauthn`, `TwoFactorBackup::verify`) → `record(request()->ip(), '2fa')`
  + `AuditEvent` `action='auth.2fa_failed'`, `target=(string) session('2fa.user')`, `ip=…`.
- **Audit payloads:** `auth.ip_banned` → `actor='system'`, `target=$ip`, `ip=$ip`, `meta=['reason'=>$reason,'attempts'=>$hits]`.
  `auth.ip_unbanned` → `actor=<operator email>`, `target=$ip`, `ip=request()->ip()`, `meta=['scope'=>'single'|'all'|'whitelist']`.

## 6. Settings tab — "Anmeldeschutz"

New top-level settings tab, key **`login-protection`** (English per R13), placed **after** the existing
`security` tab so related settings group together. Label „Anmeldeschutz" / "Login protection".

- **Component** `App\Livewire\Settings\LoginProtection` + view `livewire.settings.login-protection`;
  add the tab object to the `$tabs` array **and** the `@elseif ($tab === 'login-protection')` arm in
  [`settings/index.blade.php`](../../../resources/views/livewire/settings/index.blade.php). While there,
  add a safe `@else <livewire:settings.profile />` default to the tab switch (today an unknown `?tab=`
  renders nothing).
- **Config form** — typed component props loaded with casts and saved stringified:
  load `$this->enabled = Setting::get('bruteforce_enabled','1') === '1'`, `$this->maxretry = (int) …`;
  save `Setting::put('bruteforce_enabled', $this->enabled ? '1' : '0')`, `Setting::put('bruteforce_maxretry', (string) $this->maxretry)`, etc.
  Fields: enabled (toggle) · maxretry (int) · findtime (min) · bantime (min) · whitelist (textarea of
  IP/CIDR). Whitelist validated by a new **`app/Rules/ValidIpOrCidr`** rule (rejects junk; default value
  = the private ranges `10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16`). On save, **delete any `BannedIp`
  rows now covered by the (new) whitelist** so widening it clears matching bans immediately.
- **Current-IP self-service (lockout mitigation):** the tab shows the operator's current
  `request()->ip()` with its ban/whitelist status and a one-click **„Meine IP zur Whitelist hinzufügen"**
  — so an operator can exempt themselves before/while enabling the feature.
- **Active-bans table:** IP · reason · attempts · banned-until (relative + absolute) · **„Entbannen"**.
  A **„Alle entbannen"** action. Empty-state when none. Reads `BannedIp::where('banned_until','>',now())`.
  Both unban actions are **destructive → wire-elements/modal confirm (R5)**, matching the sessions /
  webauthn-keys tabs (no Alpine `confirm()`). Unban deletes the row(s), `Cache::forget` the IP key(s),
  and writes `auth.ip_unbanned`.

## 7. Lockout safety (it ships enabled — these are the guarantees)

- **Guests-only enforcement (§4):** an authenticated operator is never blocked and can unban their own
  IP from the tab even while it is banned. This is the primary self-lockout safeguard.
- **Always-exempt** loopback + trusted-proxy CIDR (§3) — non-configurable; prevents the proxy-collapse
  total lockout.
- **Self-whitelist** of the current IP from the tab (§6); the default whitelist already covers private
  ranges, so a LAN/VPN operator is exempt out of the box.
- **Bans are always time-bounded** (`banned_until`); they expire on their own. No permanent ban.
- **Host escape hatch:** new artisan command `clusev:unban {ip?} {--all}` (clears one or all bans,
  bypasses HTTP) **plus** an `unban) compose exec app php artisan clusev:unban "$@" ;;` case added to the
  host `clusev` wrapper (`docker/clusev/clusev`) and its usage text — so `sudo clusev unban <ip>` works
  from the VM shell, same philosophy as `clusev:reset-admin`.
- **Residual risk to surface (tab help + CHANGELOG):** an operator on a **public / CGNAT / shared** IP
  who is **not** logged in can be locked out (by their own fumbling or by an attacker sharing the IP)
  until the ban expires or they run the CLI. Mitigations above bound it; the help text states it and
  points at the CLI.
- **Disable behaviour:** turning the feature off does **not** delete `BannedIp` rows; re-enabling resumes
  any still-valid bans. Documented in the tab; covered by a test.

## 8. i18n

- Tab/config/table/unban strings → `lang/{de,en}/settings.php` (`tab_*` nav label + a `login_protection_*`
  group). The **403 block-page** strings → `lang/{de,en}/errors.php` (error-page group per R16, mirroring
  the view tree), e.g. `blocked_title` / `blocked_body`. DE + EN, identical keys, terse, no emoji (R9/R16).

## Files touched

New: `app/Models/BannedIp.php`, `database/migrations/xxxx_create_banned_ips_table.php`,
`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` (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`).
**Not touched:** `Fail2banService`, `FirewallService`, the existing RateLimiter logic, `trustProxies`.

## Testing

Feature tests (PHPUnit, `RefreshDatabase`, `Cache::flush()` in `setUp`); inject the client IP where
needed (mock `request()->ip()` / build the request) since dev has no real forwarding (§0):

- **Guard:** `maxretry` failures within `findtime` create a `BannedIp` with the right `banned_until` and
  `auth.ip_banned`; fewer do not; the counter resets after a ban; `findtime` minutes→seconds is correct.
- **Exemptions:** whitelisted, loopback (`127.0.0.1`, `::1`, `::ffff:127.0.0.1`), and trusted-proxy IPs
  are **never** counted and **never** banned, however many failures; IPv6 spellings of the same address
  are treated as one.
- **Concurrency:** the upsert is race-safe — a second ban attempt at the threshold updates, never throws
  on the unique `ip` index.
- **Middleware:** a **guest** request from a banned IP gets 403 on `/login` and another guest route; an
  **authenticated** request from the same banned IP **passes**; a non-banned guest passes; feature
  disabled ⇒ a banned-row IP passes; an **expired** ban passes.
- **Cache:** ban then unban → the next request from that IP is **not** blocked (cache busted, no stale
  positive); a fresh ban is visible within the 30 s window.
- **Hooks:** a real failed `Login` and a failed 2FA `verify` (both `TwoFactorChallenge` and
  `TwoFactorBackup`) each drive the guard to a ban at the threshold (through the actual components).
- **Settings tab:** saving persists+casts the `Setting` keys; `ValidIpOrCidr` rejects junk; the table
  lists active bans; single + bulk unban (via the confirm modal) remove rows + bust cache + audit;
  widening the whitelist deletes now-covered bans; the "whitelist my IP" action exempts the current IP;
  toggling disabled stops enforcement; re-enabling resumes a still-valid ban.
- **CLI:** `clusev:unban <ip>` and `--all` clear bans.
- **R12** browser-verify (DE+EN): the new tab loads (200, no console errors, DOM leak scan scoped to the
  tab, 3 breakpoints). The 403 `errors.blocked` view is asserted in a feature test (it returns 403, not
  200) for content + i18n + no leaks, not via the R12 200-page path.
- **R15** `/codex:review` clean; Pint clean.

## Out of scope

- The optional **host-fail2ban logfile hook** (emit a fail2ban-format auth-failure log + ship a jail
  template for an operator-run host daemon). Explicitly deferred.
- Any change to the remote-fleet `Fail2banService` / `FirewallService`, or to `trustProxies`.
- Banning on anything other than failed control-plane auth (no WAF, no per-route rules, no geo-blocking).
- A separate ban-history table or analytics — history is the `AuditEvent` stream.
