From 8ec3b0b69abd8d9f199656bcacb08ba261356343 Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 20 Jun 2026 15:12:04 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20implementation=20plan=20=E2=80=94=202FA?= =?UTF-8?q?=20backup-code=20own=20button=20+=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task-by-task TDD plan for Spec 1: shared CompletesTwoFactorChallenge trait, new TwoFactorBackup component + guest route + view, main-view redesign (form gated to TOTP users, backup button, mount->backup redirect for key-only-no-secure-context), i18n keys, and full R12/R15 verification. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-20-2fa-challenge-backup-view.md | 921 ++++++++++++++++++ 1 file changed, 921 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-20-2fa-challenge-backup-view.md diff --git a/docs/superpowers/plans/2026-06-20-2fa-challenge-backup-view.md b/docs/superpowers/plans/2026-06-20-2fa-challenge-backup-view.md new file mode 100644 index 0000000..123076d --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-2fa-challenge-backup-view.md @@ -0,0 +1,921 @@ +# 2FA Challenge — Backup Code as its own button + view — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move the backup (recovery) code off the combined 2FA challenge screen into its own button + dedicated route/view, sharing all rate-limit and login logic via a trait. + +**Architecture:** Extract the pending-user resolver, the brute-force buckets and the login success-tail from `TwoFactorChallenge` into a `CompletesTwoFactorChallenge` trait. A new `TwoFactorBackup` full-page Livewire component (guest route `/two-factor-challenge/backup`) reuses the trait and accepts only recovery codes. The main challenge view shows only the primary factor (TOTP field *or* security-key button) plus a subordinate "Backup-Code verwenden" link; a key-only user with no secure context (http + bare IP) is redirected straight to the backup view from `mount()`. IP-vs-domain factor routing (`WebauthnService::available()`) is unchanged. + +**Tech Stack:** Laravel 13, Livewire v3 (class-based, full-page components as routes), Tailwind v4, PHPUnit + `livewire/livewire` testing, Pint. Spec: `docs/superpowers/specs/2026-06-20-2fa-challenge-backup-view-design.md`. + +--- + +## Pre-flight + +- Dev stack up: `docker compose up -d` (app on :80). **Run ALL tooling inside the container (R8).** +- Branch: `feat/v1-foundation` (already checked out). +- Known flake (memory: test view-cache race): if `php artisan test` shows Blade-compile races against the live dev stack, re-run the affected filter with an isolated compiled-view dir, e.g. `docker compose exec -e VIEW_COMPILED_PATH=/tmp/views-test app php artisan test --filter=...`. + +## File structure + +| File | Responsibility | +|---|---| +| `app/Livewire/Concerns/CompletesTwoFactorChallenge.php` | **New.** Shared trait: `pendingUser()`, `getPendingHasTotpProperty()`, `webauthnAvailable()`, rate-limit buckets/helpers, `completeLogin()`. | +| `app/Livewire/Auth/TwoFactorChallenge.php` | **Modify.** Use the trait, drop the extracted methods, add the mount→backup redirect. WebAuthn methods stay here. | +| `app/Livewire/Auth/TwoFactorBackup.php` | **New.** Backup-only component: session guard + recovery-code `verify()`. | +| `resources/views/livewire/auth/two-factor-challenge.blade.php` | **Modify.** Gate the form to TOTP users, remove the backup field/hints, add the backup button. | +| `resources/views/livewire/auth/two-factor-backup.blade.php` | **New.** Backup-code field + return links. | +| `routes/web.php` | **Modify.** New `two-factor.challenge.backup` route in the `guest` group. | +| `lang/de/auth.php`, `lang/en/auth.php` | **Modify.** Add 4 keys; later remove 2 orphaned hint keys. | +| `tests/Feature/TwoFactorBackupTest.php` | **New.** Backup component coverage. | +| `tests/Feature/ChallengeFactorAdaptTest.php` | **Modify.** Flip the key-only assertion; add main-view content tests. | + +--- + +### Task 1: i18n keys + +**Files:** +- Modify: `lang/de/auth.php`, `lang/en/auth.php` + +- [ ] **Step 1: Add the 4 new German keys** + +In `lang/de/auth.php`, inside the `// ── 2FA challenge ──` block (right after the `'code' => 'Code',` line, before `'back_to_login'`), add: + +```php + 'challenge_use_backup' => 'Backup-Code verwenden', + 'backup_heading' => 'Backup-Codes', + 'backup_subtitle' => 'Gib einen Backup-Code ein, um fortzufahren.', + 'back_to_options' => 'Zurück zu Anmelde-Optionen', +``` + +- [ ] **Step 2: Add the same 4 keys in English** + +In `lang/en/auth.php`, in the matching `// ── 2FA challenge ──` block after `'code' => 'Code',`: + +```php + 'challenge_use_backup' => 'Use backup code', + 'backup_heading' => 'Backup codes', + 'backup_subtitle' => 'Enter a backup code to continue.', + 'back_to_options' => 'Back to sign-in options', +``` + +- [ ] **Step 3: Verify both files parse and the keys are present + identical** + +Run: +```bash +docker compose exec app php -r "\$de=require 'lang/de/auth.php'; \$en=require 'lang/en/auth.php'; \$k=['challenge_use_backup','backup_heading','backup_subtitle','back_to_options']; foreach(\$k as \$x){ echo \$x.': '.(isset(\$de[\$x])&&isset(\$en[\$x])?'OK':'MISSING').PHP_EOL; }" +``` +Expected: four `OK` lines. + +- [ ] **Step 4: Commit** + +```bash +git add lang/de/auth.php lang/en/auth.php +git commit -m "i18n(auth): add 2FA backup-view strings" +``` + +--- + +### Task 2: Extract the shared trait + +Pure refactor — no behaviour change. The existing 2FA test suite is the safety net. + +**Files:** +- Create: `app/Livewire/Concerns/CompletesTwoFactorChallenge.php` +- Modify: `app/Livewire/Auth/TwoFactorChallenge.php` + +- [ ] **Step 1: Create the trait** + +Create `app/Livewire/Concerns/CompletesTwoFactorChallenge.php`: + +```php +pendingResolved) { + $this->pendingUser = User::find(session('2fa.user')); + $this->pendingResolved = true; + } + + return $this->pendingUser; + } + + /** Whether the PENDING user has TOTP — the authenticator code field renders only then. */ + public function getPendingHasTotpProperty(): bool + { + return (bool) $this->pendingUser()?->hasTotp(); + } + + /** Whether to offer the security-key option for the pending user (domain+HTTPS + has a key). */ + public function webauthnAvailable(): bool + { + $user = $this->pendingUser(); + + return $user !== null + && app(WebauthnService::class)->available() + && $user->hasWebauthnCredentials(); + } + + /** + * Two auto-expiring 2FA brute-force buckets, keyed on the SERVER-side pending user id + * (not client-controlled) — a tight per-(user+IP) one plus an IP-independent per-user + * backstop, so a distributed (multi-IP) brute-force of the code is still capped. Both + * decay in minutes (never a permanent lockout; clusev:reset-admin stays open). + * + * @return array key => [maxAttempts, decaySeconds] + */ + protected function rateLimitBuckets(): array + { + $uid = (string) session('2fa.user'); + + return [ + 'two-factor:'.md5($uid.'|'.request()->ip()) => [5, 60], + 'two-factor-acct:'.md5($uid) => [20, 900], + ]; + } + + protected function assertNotRateLimited(): void + { + foreach ($this->rateLimitBuckets() as $key => [$max]) { + if (RateLimiter::tooManyAttempts($key, $max)) { + throw ValidationException::withMessages([ + 'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]), + ]); + } + } + } + + protected function hitRateLimit(): void + { + foreach ($this->rateLimitBuckets() as $key => [, $decay]) { + RateLimiter::hit($key, $decay); + } + } + + protected function clearRateLimit(): void + { + foreach (array_keys($this->rateLimitBuckets()) as $key) { + RateLimiter::clear($key); + } + } + + /** Log the verified pending user in and finish the challenge (shared success tail). */ + protected function completeLogin(User $user) + { + $remember = (bool) session('2fa.remember'); + session()->forget(['2fa.user', '2fa.remember']); + + Auth::login($user, $remember); + session()->regenerate(); + + return $this->redirectIntended(route('dashboard'), navigate: true); + } +} +``` + +- [ ] **Step 2: Rewrite `TwoFactorChallenge` to use the trait** + +Replace the entire contents of `app/Livewire/Auth/TwoFactorChallenge.php` with: + +```php +has('2fa.user')) { + return $this->redirect(route('login'), navigate: true); + } + } + + public function verify() + { + $this->validate(); + + $this->assertNotRateLimited(); + + $user = User::find(session('2fa.user')); + + if (! $user || ! $user->hasTwoFactorEnabled()) { + session()->forget(['2fa.user', '2fa.remember']); + throw ValidationException::withMessages(['code' => __('auth.session_expired')]); + } + + // Accept the TOTP code only when the pending user actually has TOTP (otherwise a null + // secret would break verifyKey); always allow a one-time backup (recovery) code. + $valid = $user->verifyTotp($this->code) || $user->useRecoveryCode($this->code); + + if (! $valid) { + $this->hitRateLimit(); + throw ValidationException::withMessages(['code' => __('auth.invalid_code')]); + } + + $this->clearRateLimit(); + + return $this->completeLogin($user); + } + + /** JSON request options for navigator.credentials.get — the JS posts the result to verifyWebauthn. */ + public function assertionOptions(WebauthnService $webauthn): array + { + $user = User::find(session('2fa.user')); + abort_unless($user && $webauthn->available() && $user->hasWebauthnCredentials(), 404); + + return $webauthn->assertionOptions($user); + } + + /** Complete the login with a verified security-key assertion (alternative to the TOTP/backup code). */ + public function verifyWebauthn(array $assertion, WebauthnService $webauthn) + { + $this->assertNotRateLimited(); + + $user = User::find(session('2fa.user')); + + if (! $user || ! $user->hasTwoFactorEnabled() || ! $webauthn->available() || ! $webauthn->verifyAssertion($user, $assertion)) { + $this->hitRateLimit(); + throw ValidationException::withMessages(['code' => __('auth.webauthn_failed')]); + } + + $this->clearRateLimit(); + + return $this->completeLogin($user); + } + + public function render() + { + return view('livewire.auth.two-factor-challenge')->title(__('auth.title_challenge')); + } +} +``` + +- [ ] **Step 3: Run the existing 2FA suite — must stay green** + +Run: +```bash +docker compose exec app php artisan test --filter='TwoFactorChallengeRecoveryTest|TwoFactorWebauthnTest|ChallengeFactorAdaptTest|BruteForceHardeningTest' +``` +Expected: PASS (behaviour unchanged — the trait just relocates the same code). + +- [ ] **Step 4: Pint + commit** + +```bash +docker compose exec app ./vendor/bin/pint app/Livewire/Concerns/CompletesTwoFactorChallenge.php app/Livewire/Auth/TwoFactorChallenge.php +git add app/Livewire/Concerns/CompletesTwoFactorChallenge.php app/Livewire/Auth/TwoFactorChallenge.php +git commit -m "refactor(auth): extract CompletesTwoFactorChallenge trait" +``` + +--- + +### Task 3: Backup route + component + view (TDD) + +**Files:** +- Create: `tests/Feature/TwoFactorBackupTest.php` +- Modify: `routes/web.php` +- Create: `app/Livewire/Auth/TwoFactorBackup.php` +- Create: `resources/views/livewire/auth/two-factor-backup.blade.php` + +- [ ] **Step 1: Write the failing test file** + +Create `tests/Feature/TwoFactorBackupTest.php`: + +```php +create([ + 'two_factor_secret' => (new Google2FA)->generateSecretKey(), + 'two_factor_confirmed_at' => now(), + ]); + } + + private function keyOnlyUser(): User + { + $u = User::factory()->create(); + WebauthnCredential::create(['user_id' => $u->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); + + return $u->fresh(); + } + + public function test_backup_code_logs_in_and_is_consumed(): void + { + $user = $this->enrolledUser(); + $code = $user->replaceRecoveryCodes()[0]; + session()->put('2fa.user', $user->id); + + Livewire::test(TwoFactorBackup::class) + ->set('code', $code) + ->call('verify') + ->assertRedirect(route('dashboard')); + + $this->assertAuthenticatedAs($user); + $this->assertFalse($user->fresh()->useRecoveryCode($code)); // consumed + } + + public function test_invalid_backup_code_fails(): void + { + $user = $this->enrolledUser(); + $user->replaceRecoveryCodes(); + session()->put('2fa.user', $user->id); + + Livewire::test(TwoFactorBackup::class) + ->set('code', 'nope-nope') + ->call('verify') + ->assertHasErrors('code'); + + $this->assertGuest(); + } + + public function test_a_totp_code_is_not_accepted_on_the_backup_view(): void + { + $user = $this->enrolledUser(); + $user->replaceRecoveryCodes(); + $totp = (new Google2FA)->getCurrentOtp($user->two_factor_secret); + session()->put('2fa.user', $user->id); + + Livewire::test(TwoFactorBackup::class) + ->set('code', $totp) + ->call('verify') + ->assertHasErrors('code'); + + $this->assertGuest(); + } + + public function test_requires_a_pending_2fa_session(): void + { + Livewire::test(TwoFactorBackup::class) + ->assertRedirect(route('login')); + } + + public function test_field_and_login_link_render(): void + { + $user = $this->enrolledUser(); + $user->replaceRecoveryCodes(); + session()->put('2fa.user', $user->id); + + Livewire::test(TwoFactorBackup::class) + ->assertSee(__('auth.backup_heading')) + ->assertSee(__('auth.challenge_backup_placeholder')) // the backup field is present + ->assertSee(__('auth.back_to_login')); + } + + public function test_back_to_options_shown_for_a_totp_user(): void + { + $user = $this->enrolledUser(); + $user->replaceRecoveryCodes(); + session()->put('2fa.user', $user->id); + + Livewire::test(TwoFactorBackup::class) + ->assertSee(__('auth.back_to_options')); + } + + public function test_back_to_options_hidden_for_key_only_without_secure_context(): void + { + // No domain configured → webauthnAvailable() false, pendingHasTotp false: backup is the + // only path, so the "back to options" link would loop straight back here and is hidden. + $user = $this->keyOnlyUser(); + $user->replaceRecoveryCodes(); + session()->put('2fa.user', $user->id); + + Livewire::test(TwoFactorBackup::class) + ->assertDontSee(__('auth.back_to_options')) + ->assertSee(__('auth.back_to_login')); + } + + public function test_failed_backup_attempts_share_the_rate_limit_with_the_main_challenge(): void + { + $user = $this->enrolledUser(); + $codes = $user->replaceRecoveryCodes(); + session()->put('2fa.user', $user->id); + + // 5 wrong backup attempts trip the per-(user+IP) bucket (5/60s). + for ($i = 0; $i < 5; $i++) { + Livewire::test(TwoFactorBackup::class)->set('code', 'wrong-'.$i)->call('verify'); + } + + // A genuinely valid, unused backup code is now refused on the MAIN challenge by the + // SAME shared bucket — proving one counter spans both components. + Livewire::test(TwoFactorChallenge::class) + ->set('code', $codes[0]) + ->call('verify') + ->assertHasErrors('code'); + + $this->assertGuest(); + } +} +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: +```bash +docker compose exec app php artisan test --filter=TwoFactorBackupTest +``` +Expected: FAIL — `Class "App\Livewire\Auth\TwoFactorBackup" not found` (and the route does not exist yet). + +- [ ] **Step 3: Add the route** + +In `routes/web.php`, in the `guest` middleware group, immediately after the existing `two-factor.challenge` line, add the backup sibling: + +```php + Route::get('/two-factor-challenge', Auth\TwoFactorChallenge::class)->name('two-factor.challenge'); + Route::get('/two-factor-challenge/backup', Auth\TwoFactorBackup::class)->name('two-factor.challenge.backup'); +``` + +- [ ] **Step 4: Create the `TwoFactorBackup` component** + +Create `app/Livewire/Auth/TwoFactorBackup.php`: + +```php +has('2fa.user')) { + return $this->redirect(route('login'), navigate: true); + } + } + + public function verify() + { + $this->validate(); + + $this->assertNotRateLimited(); + + $user = User::find(session('2fa.user')); + + if (! $user || ! $user->hasTwoFactorEnabled()) { + session()->forget(['2fa.user', '2fa.remember']); + throw ValidationException::withMessages(['code' => __('auth.session_expired')]); + } + + // Backup-only view: a one-time recovery code is the sole accepted credential (no TOTP). + if (! $user->useRecoveryCode($this->code)) { + $this->hitRateLimit(); + throw ValidationException::withMessages(['code' => __('auth.invalid_code')]); + } + + $this->clearRateLimit(); + + return $this->completeLogin($user); + } + + public function render() + { + return view('livewire.auth.two-factor-backup')->title(__('auth.title_challenge')); + } +} +``` + +- [ ] **Step 5: Create the backup view** + +Create `resources/views/livewire/auth/two-factor-backup.blade.php`: + +```blade +@php + $fieldText = 'h-12 w-full rounded-md border border-line bg-inset px-3 text-center font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none'; + $label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3'; + $err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline'; +@endphp + +
+
+

{{ __('auth.two_factor') }}

+

{{ __('auth.backup_heading') }}

+

{{ __('auth.backup_subtitle') }}

+
+ +
+
+ + + @error('code')

{{ $message }}

@enderror +
+ + + + {{ __('common.confirm') }} + {{ __('auth.checking') }} + +
+ +
+ @if ($this->pendingHasTotp || $this->webauthnAvailable()) + + {{ __('auth.back_to_options') }} + + @endif + + {{ __('auth.back_to_login') }} + +
+
+``` + +- [ ] **Step 6: Run the test — must pass** + +Run: +```bash +docker compose exec app php artisan test --filter=TwoFactorBackupTest +``` +Expected: PASS (all 8 tests). + +- [ ] **Step 7: Pint + commit** + +```bash +docker compose exec app ./vendor/bin/pint routes/web.php app/Livewire/Auth/TwoFactorBackup.php tests/Feature/TwoFactorBackupTest.php +git add routes/web.php app/Livewire/Auth/TwoFactorBackup.php resources/views/livewire/auth/two-factor-backup.blade.php tests/Feature/TwoFactorBackupTest.php +git commit -m "feat(auth): dedicated backup-code 2FA view + route" +``` + +--- + +### Task 4: Main view redesign + mount redirect (TDD) + +The backup route now exists, so `TwoFactorChallenge::mount()` can safely redirect to it. + +**Files:** +- Modify: `tests/Feature/ChallengeFactorAdaptTest.php` +- Modify: `app/Livewire/Auth/TwoFactorChallenge.php` +- Modify: `resources/views/livewire/auth/two-factor-challenge.blade.php` + +- [ ] **Step 1: Update `ChallengeFactorAdaptTest` to the new behaviour (failing)** + +Replace the entire contents of `tests/Feature/ChallengeFactorAdaptTest.php` with: + +```php +create(); + WebauthnCredential::create(['user_id' => $u->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); + + return $u->fresh(); + } + + public function test_totp_user_sees_the_field(): void + { + $user = User::factory()->create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]); + session(['2fa.user' => $user->id, '2fa.remember' => false]); + + Livewire::test(TwoFactorChallenge::class)->assertSet('pendingHasTotp', true); + } + + public function test_totp_user_sees_the_backup_link_but_not_the_backup_field(): void + { + $user = User::factory()->create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]); + session(['2fa.user' => $user->id, '2fa.remember' => false]); + + Livewire::test(TwoFactorChallenge::class) + ->assertSee('000000') // TOTP field present + ->assertSee(__('auth.challenge_use_backup')) // backup link present + ->assertDontSee(__('auth.challenge_backup_placeholder')); // no backup field here + } + + public function test_key_only_user_without_secure_context_is_redirected_to_the_backup_view(): void + { + // No domain configured → webauthnAvailable() false, pendingHasTotp false: the backup code + // is the only usable path, so mount() sends the user straight to the dedicated view. + $user = $this->keyOnlyUser(); + session(['2fa.user' => $user->id, '2fa.remember' => false]); + + Livewire::test(TwoFactorChallenge::class) + ->assertRedirect(route('two-factor.challenge.backup')); + } + + public function test_key_only_user_with_secure_context_stays_and_shows_key_plus_backup(): void + { + $svc = $this->mock(WebauthnService::class); + $svc->shouldReceive('available')->andReturn(true); + + $user = $this->keyOnlyUser(); + session(['2fa.user' => $user->id, '2fa.remember' => false]); + + Livewire::test(TwoFactorChallenge::class) + ->assertNoRedirect() + ->assertSee(__('auth.webauthn_login')) // security-key button + ->assertSee(__('auth.challenge_use_backup')) // backup link + ->assertDontSee('000000') // no TOTP field + ->assertDontSee(__('auth.challenge_backup_placeholder')); // no inline backup field + } +} +``` + +- [ ] **Step 2: Run it — confirm the new assertions fail** + +Run: +```bash +docker compose exec app php artisan test --filter=ChallengeFactorAdaptTest +``` +Expected: FAIL — the redirect test fails (no redirect yet) and the "no backup field" assertions fail (the field is still rendered). + +- [ ] **Step 3: Add the mount redirect to `TwoFactorChallenge`** + +In `app/Livewire/Auth/TwoFactorChallenge.php`, replace the `mount()` method with: + +```php + public function mount() + { + if (! session()->has('2fa.user')) { + return $this->redirect(route('login'), navigate: true); + } + + // No TOTP and no usable security key (a key-only user over http + bare IP, where WebAuthn + // has no secure context) → the backup code is the only path: go straight to its own view. + if (! $this->pendingHasTotp && ! $this->webauthnAvailable()) { + return $this->redirect(route('two-factor.challenge.backup'), navigate: true); + } + } +``` + +- [ ] **Step 4: Redesign the main challenge view** + +Replace the entire contents of `resources/views/livewire/auth/two-factor-challenge.blade.php` with: + +```blade +@php + $field = 'h-14 w-full rounded-md border border-line bg-inset text-center font-mono text-2xl font-semibold tracking-[0.5em] text-ink caret-accent placeholder:text-ink-4 focus:border-accent/40 focus:outline-none'; + $label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3'; + $err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline'; +@endphp + +
+
+

{{ __('auth.two_factor') }}

+

{{ __('auth.challenge_heading') }}

+

{{ $this->pendingHasTotp ? __('auth.challenge_subtitle') : __('auth.challenge_key_subtitle') }}

+
+ + @if (! $this->pendingHasTotp && $this->webauthnAvailable()) + {{-- Key-only: the security key is the primary path. --}} + + {{ __('auth.webauthn_login') }} + + @endif + + @if ($this->pendingHasTotp) +
+
+ + + @error('code')

{{ $message }}

@enderror +
+ + + + {{ __('common.confirm') }} + {{ __('auth.checking') }} + +
+ + @if ($this->webauthnAvailable()) + {{-- TOTP + key: the security key is the alternate path. --}} +
+ + {{ __('common.or') }} + +
+ + {{ __('auth.webauthn_login') }} + + @endif + @endif + + {{-- Backup code lives on its own view — a deliberate, subordinate step. --}} + + {{ __('auth.challenge_use_backup') }} + + + +
+``` + +(Compared to the old view: `$fieldText` removed from the `@php` block; the always-on backup field, the `challenge_recovery_hint` and the `challenge_backup_only_hint` paragraphs are gone; the form is wrapped in `@if ($this->pendingHasTotp)`; the "Backup-Code verwenden" button is added before the back-to-login link.) + +- [ ] **Step 5: Run the adapt test — must pass** + +Run: +```bash +docker compose exec app php artisan test --filter=ChallengeFactorAdaptTest +``` +Expected: PASS (4 tests). + +- [ ] **Step 6: Run the whole 2FA + auth suite — guard against regressions** + +Run: +```bash +docker compose exec app php artisan test --filter='TwoFactor|Challenge|Webauthn|BruteForce|Login|Factor' +``` +Expected: PASS. (If `BruteForceHardeningTest` or another suite asserted the old combined-view backup field, fix that assertion to key on `challenge_backup_placeholder` per the substring caveat below, then re-run.) + +> **Substring caveat:** never assert `assertSee/DontSee(__('auth.challenge_backup_label'))` to detect the backup field — `challenge_backup_label` ("Backup-Code") is a substring of `challenge_use_backup` ("Backup-Code verwenden"). Use `__('auth.challenge_backup_placeholder')` (`xxxxxxxxxx-xxxxxxxxxx`), which is unique to the field. + +- [ ] **Step 7: Pint + commit** + +```bash +docker compose exec app ./vendor/bin/pint app/Livewire/Auth/TwoFactorChallenge.php tests/Feature/ChallengeFactorAdaptTest.php +git add app/Livewire/Auth/TwoFactorChallenge.php resources/views/livewire/auth/two-factor-challenge.blade.php tests/Feature/ChallengeFactorAdaptTest.php +git commit -m "feat(auth): split backup code out of the main 2FA challenge view" +``` + +--- + +### Task 5: Remove the orphaned hint keys + +`challenge_recovery_hint` and `challenge_backup_only_hint` are no longer rendered by any view. + +**Files:** +- Modify: `lang/de/auth.php`, `lang/en/auth.php` + +- [ ] **Step 1: Confirm they are unreferenced** + +Run: +```bash +docker compose exec app grep -rn "challenge_recovery_hint\|challenge_backup_only_hint" resources/ app/ || echo "NO REFERENCES" +``` +Expected: `NO REFERENCES`. (If any line prints, stop — that view still needs the key; do not delete it.) + +- [ ] **Step 2: Delete the two keys from both language files** + +Remove these lines from `lang/de/auth.php`: +```php + 'challenge_recovery_hint' => 'Authenticator verloren? Gib einen deiner Backup-Codes ein.', + 'challenge_backup_only_hint' => 'Kein Authenticator nötig — Security-Key oder ein Backup-Code.', +``` +And from `lang/en/auth.php`: +```php + 'challenge_recovery_hint' => 'Lost your authenticator? Enter one of your backup codes.', + 'challenge_backup_only_hint' => 'No authenticator needed — use a security key or a backup code.', +``` + +- [ ] **Step 3: Verify both files still parse** + +Run: +```bash +docker compose exec app php -r "require 'lang/de/auth.php'; require 'lang/en/auth.php'; echo 'OK'.PHP_EOL;" +``` +Expected: `OK`. + +- [ ] **Step 4: Commit** + +```bash +git add lang/de/auth.php lang/en/auth.php +git commit -m "i18n(auth): drop orphaned 2FA recovery hint strings" +``` + +--- + +### Task 6: Full verification (R12 browser + R15 Codex) + +**Files:** none (verification only). + +- [ ] **Step 1: Full test suite green** + +Run: +```bash +docker compose exec app php artisan test +``` +Expected: PASS, 0 failures. + +- [ ] **Step 2: Build assets** + +Run: +```bash +docker compose exec app npm run build +``` +Expected: build completes, no errors. + +- [ ] **Step 3: R12 browser verify — `/two-factor-challenge` (DE + EN)** + +With a pending-2FA session for a TOTP user, load `/two-factor-challenge` headless. Confirm: +- HTTP 200, zero console/network errors. +- Rendered DOM shows the TOTP field + the "Backup-Code verwenden" button + "Zurück zur Anmeldung"; **no** inline backup field (`xxxxxxxxxx-xxxxxxxxxx` placeholder absent). +- No leaked `@`/`{{ }}`/`$var`/`group.key` text (R17). +- 3 breakpoints (375 / 768 / 1280), touch targets ≥44px. + +(Bare-IP setup: a key-only pending session should 302/redirect to `/two-factor-challenge/backup` — verify the redirect lands.) + +- [ ] **Step 4: R12 browser verify — `/two-factor-challenge/backup` (DE + EN)** + +Load `/two-factor-challenge/backup` with a pending-2FA session. Confirm HTTP 200, zero console/network errors, the backup field + "Bestätigen" + the correct return links (for a TOTP user both "Zurück zu Anmelde-Optionen" and "Zurück zur Anmeldung"), no leaked template tokens, 3 breakpoints. + +- [ ] **Step 5: R15 — Codex review clean** + +Run `/codex:review` over the branch diff. Fix anything it flags (errors or security) and re-run until it reports no errors and no security issues. + +- [ ] **Step 6: Final Pint** + +Run: +```bash +docker compose exec app ./vendor/bin/pint --dirty +``` +Expected: no changes / all clean. Commit if Pint reformatted anything. + +--- + +## Self-review notes (already reconciled with the spec) + +- **Shared rate-limit bucket** — Task 2 keeps the exact bucket key format; Task 3's cross-component test proves the counter spans both views. +- **No property→method churn** — the computed property `getPendingHasTotpProperty()` moves into the trait, so every `$this->pendingHasTotp` blade/test usage is unchanged. +- **Redirect-loop guard** — `back_to_options` is hidden exactly when `! pendingHasTotp && ! webauthnAvailable()` (covered by `TwoFactorBackupTest::test_back_to_options_hidden_for_key_only_without_secure_context`). +- **Substring trap** — backup-field assertions use `challenge_backup_placeholder`, never `challenge_backup_label`. +- **Out of scope (Spec 2):** auth-failure → fail2ban hard IP ban. Not in this plan.