docs: spec — split 2FA backup code into its own button + view
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 <noreply@anthropic.com>feat/v1-foundation
parent
b619b320c2
commit
0f5d14a252
|
|
@ -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 `<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.
|
||||
Loading…
Reference in New Issue