# 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 `private` — an internal memoisation detail; each component gets its
  own copy, and Livewire persists only public props, so the visibility is runtime-irrelevant).
- `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 `<form wire:submit="verify">` 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 `</form>`, 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.
