From 0f5d14a252736d5c427809f406d3fc43141e9442 Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 20 Jun 2026 15:01:39 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20spec=20=E2=80=94=20split=202FA=20backup?= =?UTF-8?q?=20code=20into=20its=20own=20button=20+=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 1 of 2 for the 2FA challenge rework. Moves the backup (recovery) code off the combined challenge screen into a dedicated /two-factor-challenge/backup route + view, behind a 'Backup-Code verwenden' button; the main screen shows only the primary factor. A key-only user without secure context (http + bare IP) is redirected straight to the backup view. Shared rate-limit/login logic is extracted into a CompletesTwoFactorChallenge trait so both views hit one bucket set. IP-vs-domain factor routing is already correct (WebauthnService::available) and unchanged. Spec 2 (auth-failure -> fail2ban hard IP ban) is out of scope here. Co-Authored-By: Claude Opus 4.8 --- ...-06-20-2fa-challenge-backup-view-design.md | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-20-2fa-challenge-backup-view-design.md diff --git a/docs/superpowers/specs/2026-06-20-2fa-challenge-backup-view-design.md b/docs/superpowers/specs/2026-06-20-2fa-challenge-backup-view-design.md new file mode 100644 index 0000000..4512b9d --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-2fa-challenge-backup-view-design.md @@ -0,0 +1,214 @@ +# 2FA Challenge — Backup Code as its own button + view — Design + +**Date:** 2026-06-20 · **Branch:** `feat/v1-foundation` · **Status:** approved + +Splits the backup (recovery) code out of the combined two-factor challenge screen. Today +`TwoFactorChallenge` renders the primary factor **and** a backup-code field on the same view (key +button + "ODER" + backup field for a key user; TOTP field + recovery hint for a TOTP user). The +backup code becomes a deliberate, separate step: a **"Backup-Code verwenden"** button on the main +screen that leads to a **dedicated backup-only view/route**. The main screen shows only the primary +factor (TOTP field *or* security-key button). + +Why: a backup code is a sensitive, rarely-used recovery secret — surfacing it next to the everyday +factor invites accidental use and shoulder-surfing. Separating it also makes the "only backup is +usable" path (a key-only user reaching the panel over **http + bare IP**, where WebAuthn has no +secure context) land directly on the backup view instead of a half-disabled combined screen. + +The IP-vs-domain routing the user asked about is **already correct** and unchanged: +`WebauthnService::available()` returns true only when the request host equals the configured domain +(HTTPS enforced at the front door), so the security-key button is offered on a domain and absent on +bare IP; TOTP works on any host. This spec only restructures the **UI** around that existing logic. + +This is **Spec 1 of 2**. Spec 2 (auth-failure → fail2ban hard IP ban on the firewall) is a +separate, infrastructure-layer change, planned next; see *Out of scope*. + +--- + +## 1. New component + route + +- **New full-page Livewire component** `App\Livewire\Auth\TwoFactorBackup` + view + `resources/views/livewire/auth/two-factor-backup.blade.php` (R1/R2/R6, `#[Layout('layouts.auth')]`). +- **New route** in the existing `guest` group in `routes/web.php`, immediately after the + `two-factor.challenge` line: + `Route::get('/two-factor-challenge/backup', Auth\TwoFactorBackup::class)->name('two-factor.challenge.backup');` + English path + name (R13); sibling of `two-factor.challenge`, same `guest` middleware (the 2FA + flow runs before the session is authenticated). + +## 2. Shared trait — `App\Livewire\Concerns\CompletesTwoFactorChallenge` + +Extract the logic currently inline in `TwoFactorChallenge` into a trait used by **both** challenge +components, so there is no duplication and **one shared rate-limit bucket set**: + +- `pendingUser(): ?User` — the memoised `User::find(session('2fa.user'))` resolver (move the existing + one; keep its backing state `protected` so both components share it). +- `getPendingHasTotpProperty(): bool` — **keep the existing Livewire computed-property magic getter** + (move it verbatim into the trait, do **not** turn it into a plain `pendingHasTotp()` method). This + keeps every blade's `$this->pendingHasTotp` usage working unchanged — **no property→method rewrites + in the challenge blade**. `webauthnAvailable(): bool` stays a plain method (the blade already calls + it with parens); it delegates to `WebauthnService::available()` + `hasWebauthnCredentials()`. Both + views thus agree on what is offered. +- Rate limiting — **preserve the exact current bucket keys verbatim** so the counter is genuinely + shared across both components (a divergent key format would silently break the shared-limit + guarantee): with `$uid = (string) session('2fa.user')`, the buckets are + `'two-factor:'.md5($uid.'|'.request()->ip())` [5/60s] and `'two-factor-acct:'.md5($uid)` [20/900s]; + plus `rateLimitBuckets()`, `assertNotRateLimited()`, `hitRateLimit()`, `clearRateLimit()` moved + unchanged from `TwoFactorChallenge` lines 56–89. +- `completeLogin(User $user)` — the shared success tail: read `2fa.remember`, forget `2fa.*`, + `Auth::login($user, $remember)`, `session()->regenerate()`, `redirectIntended(route('dashboard'), + navigate: true)`. + +**Stays on `TwoFactorChallenge` only:** `assertionOptions()` and `verifyWebauthn()`. The frontend +`window.clusevWebauthn.login($wire)` (`resources/js/webauthn.js`) calls exactly these two Livewire +methods; their names/signatures must not change, and the backup view has no key button so it needs +neither the methods nor `webauthn.js`. + +## 3. Main challenge view — backup field removed, button added + +`TwoFactorChallenge` / `two-factor-challenge.blade.php`: + +- **Gate the code form to TOTP users.** Wrap the entire `
` block in + `@if ($this->pendingHasTotp())` and **delete** the backup-code text field plus the inline backup + hints (`challenge_recovery_hint` / `challenge_backup_only_hint`) that lived inside it. Result: a + TOTP user sees the `000000` code field (label `auth.code`); a key-only user sees **no** text field — + just the security-key button. The `$this->pendingHasTotp` blade syntax is **unchanged** (the + computed property moves to the trait, §2), so these are **structural** edits only — no + property→method rewrites of the existing usages. +- `verify()` is **unchanged** — it keeps `verifyTotp($code) || useRecoveryCode($code)`; a backup code + pasted into the TOTP field still works as a forgiving fallback, it is just no longer the advertised + affordance here. +- **Add** a subordinate **"Backup-Code verwenden"** button (`auth.challenge_use_backup`) navigating + (`wire:navigate`) to `route('two-factor.challenge.backup')`, placed after the `
`, visually + below the primary factor and **less prominent** than the primary button (a link/ghost or + low-emphasis treatment — it must not compete with the security-key button, which is itself + `secondary` on the TOTP+key view). +- **"Only backup is usable" guard** — in `mount()`, after the existing `session('2fa.user')` guard: + `if (! $this->pendingHasTotp() && ! $this->webauthnAvailable()) return $this->redirect(route('two-factor.challenge.backup'), navigate: true);`. + This fires **only** for a user with no TOTP **and** no usable security key — i.e. a key-only user on + http + bare IP (no secure context). It can never strand a TOTP user (`pendingHasTotp() == true`) nor + a key-on-domain user (`webauthnAvailable() == true`). Safe because `session('2fa.user')` is set only + after `Login` verifies `hasTwoFactorEnabled()` (`app/Livewire/Auth/Login.php`, the + redirect-to-challenge branch), so "neither factor usable" can only mean key-only-without-secure-context, + never "no factor at all". + +## 4. Backup view — `two-factor-backup.blade.php` + +Backup-only screen rendered into the `layouts.auth` `$slot`: + +- `#[Validate('required|string')] public string $code` and a `render()` titled `auth.title_challenge` + (mirror `TwoFactorChallenge`). `mount()`: if `! session()->has('2fa.user')` → `redirect(route('login'))` + (same guard as the main view; runs before anything else — no rate-limit needed at mount, the upstream + `Login` already throttled this session). +- **Input UX parity with the main view** (so R12 quality matches): heading `auth.backup_heading`, + subtitle `auth.backup_subtitle`; one text field reusing `auth.challenge_backup_label` + + `auth.challenge_backup_placeholder` with `autofocus`, `autocomplete="one-time-code"`, + `inputmode="text"` and the main view's `$fieldText` mono style; a `Bestätigen` (`common.confirm`) + submit carrying the same `wire:loading` spinner + `auth.checking` (`Prüfe…`) state. +- `verify()`: `validate()` → `assertNotRateLimited()` → resolve the pending user → require + `hasTwoFactorEnabled()` (else forget `2fa.*` + `auth.session_expired`) → attempt **only** + `$user->useRecoveryCode($this->code)` (**not** `verifyTotp` — this view is backup-only) → on success + `clearRateLimit()` + `completeLogin()`; on failure `hitRateLimit()` + `auth.invalid_code`. Shares the + §2 buckets, so a wrong backup code here throttles the same counter as the main view and vice-versa. +- **Return links (redirect-loop guard):** + - "Zurück zu Anmelde-Optionen" (`auth.back_to_options`) → `route('two-factor.challenge')`, shown + **only when `$this->pendingHasTotp() || $this->webauthnAvailable()`**. For the key-only-on-http+IP + user neither holds, so the link is hidden — otherwise it would bounce straight back here via the §3 + mount guard. (Anyone who reached this view via the button has at least one of the two true, so the + link is present for them.) + - "Zurück zur Anmeldung" (`auth.back_to_login`) → `route('login')`, always shown. + +## 5. Behaviour matrix (after the change) + +| User / context | Main challenge screen | Backup reached via | +|---|---|---| +| TOTP (any host) | TOTP field + "Backup-Code verwenden" | button | +| Key-only, domain + HTTPS | Security-key button + "Backup-Code verwenden" | button | +| Key-only, http + bare IP | — (mount redirects) | **auto-redirect** | +| TOTP + key, domain | TOTP field + key button + "Backup-Code verwenden" | button | +| TOTP + key, http + IP | TOTP field + "Backup-Code verwenden" (key absent) | button | + +## 6. Security notes + +- **Method labels are not an information leak** (the user's point 6). Anyone seeing this screen has + already passed the password and is on the account; which factors exist is observable from the + rendered HTML/JS (the WebAuthn assertion options, the field type) regardless of button wording. + Hiding "Mit Security-Key anmelden" would be security theatre with no protection, so the explicit, + clear labels are kept — matching GitHub/Google. The real wins are the separate backup view + (reduces accidental/shoulder-surfed backup use) and Spec 2 (a true IP ban). +- **Rate limiting is unchanged and already covers backup codes** (the user's point 2): the backup + view shares the exact same buckets via the trait, so backup attempts count against the same + per-(user+IP) 5/60s and per-user 20/900s caps as TOTP and the key path. This remains a *soft, + self-expiring throttle*, never a permanent lockout (`clusev:reset-admin` stays open) — a hard + firewall-level IP ban is **Spec 2**, not this change. +- No change to the WebAuthn ceremony, the rate-limit buckets, or `WebauthnService::available()`. + +## 7. i18n (`lang/{de,en}/auth.php`) + +New keys (DE / EN), terse register, no emoji (R9/R16): + +| Key | German | English | +|---|---|---| +| `challenge_use_backup` | Backup-Code verwenden | Use backup code | +| `backup_heading` | Backup-Codes | Backup codes | +| `backup_subtitle` | Gib einen Backup-Code ein, um fortzufahren. | Enter a backup code to continue. | +| `back_to_options` | Zurück zu Anmelde-Optionen | Back to sign-in options | + +Reused unchanged: `challenge_backup_label`, `challenge_backup_placeholder`, `two_factor`, +`back_to_login`, `too_many_attempts`, `invalid_code`, `session_expired`, `checking`, +`title_challenge`, `common.confirm`. The `challenge_recovery_hint` / `challenge_backup_only_hint` +hints are removed from the main view and are **not** reused by the backup view (it uses +`backup_subtitle`); after a `grep -rn` over `resources/` confirms no remaining references, delete both +keys from `lang/{de,en}/auth.php`. + +## Files touched + +`routes/web.php` (new route), new `app/Livewire/Auth/TwoFactorBackup.php` + view, new +`app/Livewire/Concerns/CompletesTwoFactorChallenge.php`, `app/Livewire/Auth/TwoFactorChallenge.php` +(use trait, drop the extracted methods, add the mount redirect + the backup button), +`resources/views/livewire/auth/two-factor-challenge.blade.php` (remove backup field/hints, add +button; structural only — no `pendingHasTotp` syntax change), `lang/de/auth.php`, `lang/en/auth.php`, +and `tests/Feature/ChallengeFactorAdaptTest.php` (its key-only case asserts `auth.challenge_backup_label` +is visible on the **main** view — that field moves out, so flip that assertion to `assertDontSee` and +cover the backup-code entry in a new `TwoFactorBackup` test). No model, service, or JS changes. + +## Testing + +Feature tests (PHPUnit, `RefreshDatabase`, `Cache::flush()` in `setUp`), mirroring the existing 2FA +tests (`TwoFactorChallengeRecoveryTest`, `ChallengeFactorAdaptTest`); seed pending state via +`session()->put('2fa.user', $user->id)`; build factors with `two_factor_secret`+`two_factor_confirmed_at` +(TOTP), `WebauthnCredential::create([...])` (key), `replaceRecoveryCodes()` (backup): + +- **Backup view happy path:** `Livewire::test(TwoFactorBackup::class)->set('code', $code)->call('verify')` + → `assertAuthenticatedAs($user)`, redirect to `dashboard`, code consumed + (`$user->fresh()->useRecoveryCode($code)` now false). +- **Backup view wrong code:** `->assertHasErrors('code')` + `assertGuest()`. +- **Backup view rate limit:** the shared buckets trip after the same threshold (6th wrong attempt + refused) — assert a `TwoFactorBackup` failure also throttles a `TwoFactorChallenge` attempt and + vice-versa (shared bucket). +- **Backup view session guard:** no `2fa.user` → redirect to `login`. +- **Main view redirect:** key-only pending user with `webauthnAvailable()` mocked **false** (bare IP) + → `mount` redirects to `two-factor.challenge.backup`; with it **true** (domain) → no redirect, + key button + backup link shown. +- **Main view content:** TOTP user sees the code field + the backup link and **not** a backup field; + key-only-on-domain user sees the key button + backup link and **no** text field. +- **Regression:** the existing 2FA suite (`TwoFactorChallengeRecoveryTest`, `ChallengeFactorAdaptTest`, + `BruteForceHardeningTest`, `TwoFactorWebauthnTest`) stays green — in particular flip + `ChallengeFactorAdaptTest`'s main-view `assertSee(auth.challenge_backup_label)` for the key-only user + to `assertDontSee`, with the backup-code path re-asserted on the new `TwoFactorBackup` test. +- **Return-link guard:** backup view for a TOTP/key user shows `back_to_options`; for a + key-only-no-secure-context user it does **not** (only `back_to_login`). +- **R12 browser verify** (DE + EN) on **both** `/two-factor-challenge` and + `/two-factor-challenge/backup`: HTTP 200, zero console/network errors, rendered-DOM scan for + leaked `@`/`{{ }}`/`$var`/`group.key` text (R17), the 3 breakpoints (375/768/1280). Bare-IP key + E2E stays domain-deferred (WebAuthn needs the domain). +- **R15:** `/codex:review` clean (no errors, no security issues); Pint clean. + +## Out of scope + +- **Spec 2 — auth-failure → fail2ban hard IP ban.** A real firewall-level block (Clusev writes + failed-auth events with the client IP to a watched log; a fail2ban jail + filter bans the IP; + lockout-safety / whitelist so the operator can't permanently self-ban). Separate + infrastructure-layer spec, brainstormed next. +- A full **method-chooser** landing screen (chosen against: one extra click for the 90% case; the + primary factor stays the default and backup is one deliberate step away). +- Any change to the WebAuthn ceremony, the rate-limit bucket sizes, TOTP verification, or + `WebauthnService::available()` host logic.