15 KiB
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+ viewresources/views/livewire/auth/two-factor-backup.blade.php(R1/R2/R6,#[Layout('layouts.auth')]). - New route in the existing
guestgroup inroutes/web.php, immediately after thetwo-factor.challengeline:Route::get('/two-factor-challenge/backup', Auth\TwoFactorBackup::class)->name('two-factor.challenge.backup');English path + name (R13); sibling oftwo-factor.challenge, sameguestmiddleware (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 memoisedUser::find(session('2fa.user'))resolver (move the existing one; keep its backing stateprivate— 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 plainpendingHasTotp()method). This keeps every blade's$this->pendingHasTotpusage working unchanged — no property→method rewrites in the challenge blade.webauthnAvailable(): boolstays a plain method (the blade already calls it with parens); it delegates toWebauthnService::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]; plusrateLimitBuckets(),assertNotRateLimited(),hitRateLimit(),clearRateLimit()moved unchanged fromTwoFactorChallengelines 56–89. completeLogin(User $user)— the shared success tail: read2fa.remember, forget2fa.*,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 the000000code field (labelauth.code); a key-only user sees no text field — just the security-key button. The$this->pendingHasTotpblade 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 keepsverifyTotp($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) toroute('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 itselfsecondaryon the TOTP+key view). - "Only backup is usable" guard — in
mount(), after the existingsession('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 becausesession('2fa.user')is set only afterLoginverifieshasTwoFactorEnabled()(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 $codeand arender()titledauth.title_challenge(mirrorTwoFactorChallenge).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 upstreamLoginalready throttled this session).- Input UX parity with the main view (so R12 quality matches): heading
auth.backup_heading, subtitleauth.backup_subtitle; one text field reusingauth.challenge_backup_label+auth.challenge_backup_placeholderwithautofocus,autocomplete="one-time-code",inputmode="text"and the main view's$fieldTextmono style; aBestätigen(common.confirm) submit carrying the samewire:loadingspinner +auth.checking(Prüfe…) state. verify():validate()→assertNotRateLimited()→ resolve the pending user → requirehasTwoFactorEnabled()(else forget2fa.*+auth.session_expired) → attempt only$user->useRecoveryCode($this->code)(notverifyTotp— this view is backup-only) → on successclearRateLimit()+completeLogin(); on failurehitRateLimit()+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.
- "Zurück zu Anmelde-Optionen" (
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-adminstays 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 todashboard, 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
TwoFactorBackupfailure also throttles aTwoFactorChallengeattempt and vice-versa (shared bucket). - Backup view session guard: no
2fa.user→ redirect tologin. - Main view redirect: key-only pending user with
webauthnAvailable()mocked false (bare IP) →mountredirects totwo-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 flipChallengeFactorAdaptTest's main-viewassertSee(auth.challenge_backup_label)for the key-only user toassertDontSee, with the backup-code path re-asserted on the newTwoFactorBackuptest. - 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 (onlyback_to_login). - R12 browser verify (DE + EN) on both
/two-factor-challengeand/two-factor-challenge/backup: HTTP 200, zero console/network errors, rendered-DOM scan for leaked@/{{ }}/$var/group.keytext (R17), the 3 breakpoints (375/768/1280). Bare-IP key E2E stays domain-deferred (WebAuthn needs the domain). - R15:
/codex:reviewclean (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.