clusev/docs/superpowers/specs/2026-06-20-2fa-challenge-ba...

15 KiB
Raw Blame History

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(): boolkeep 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 5689.
  • 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.