# Optional, Pluggable 2FA 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:** Make 2FA optional and pluggable — the operator secures the account with TOTP, a security key (WebAuthn), both, or nothing — replacing forced-TOTP onboarding and the dedicated recovery-codes page (now a modal). **Architecture:** `User` gains a per-factor predicate (`hasTotp()`) while `hasTwoFactorEnabled()` becomes "either factor". Onboarding stops forcing 2FA (only the password rotation stays forced). Recovery codes are generated on the first factor, cleared on the last, shown in a `wire-elements/modal`. The login challenge and Settings adapt to whichever factor(s) exist. **Tech Stack:** Laravel 13, Livewire 3 (class-based), wire-elements/modal, pragmarx/google2fa-qrcode (TOTP), web-auth/webauthn-lib (WebAuthn), Tailwind v4, bilingual `lang/{de,en}` (R16). **Spec:** `docs/superpowers/specs/2026-06-14-optional-pluggable-2fa-design.md` --- ## Conventions for every task - Run all tooling in the container: `docker compose exec -T app php artisan test --filter `, `docker compose exec -T app ./vendor/bin/pint `. - UI strings are localized in **both** `lang/de/*` and `lang/en/*` with identical keys (R16); no emoji (R9). - Destructive confirmations use `wire-elements/modal` (R5), never `confirm()`/`wire:confirm`. - After the last task: rebuild assets, R12 browser verify (bare-IP, DE+EN), Codex review (R15) clean, then release. ## File Structure | File | Responsibility | Change | |---|---|---| | `app/Models/User.php` | Factor predicates + recovery-code lifecycle | add `hasTotp()`, redefine `hasTwoFactorEnabled()`/`securityOnboarded()`, add `resetIfNoFactor()` | | `app/Http/Middleware/EnsureSecurityOnboarded.php` | Onboarding gate | drop the 2FA redirect | | `app/Livewire/Auth/PasswordChange.php` | Forced password rotation | redirect to dashboard | | `app/Livewire/Auth/TwoFactorSetup.php` | TOTP enrollment | app layout, guard on `hasTotp()`, generate codes + open modal | | `app/Livewire/Auth/TwoFactorChallenge.php` | Login second step | TOTP check only when `hasTotp()`; field gated | | `app/Livewire/Auth/ForgotPassword.php` | Password recovery proof | TOTP check only when `hasTotp()` | | `app/Livewire/Settings/Index.php` | Manage TOTP factor | remove-TOTP always allowed + `resetIfNoFactor()`; hint flag | | `app/Livewire/Settings/WebauthnKeys.php` | Manage keys | gate on `available()` only; first-key codes; last-key reset | | `app/Livewire/Modals/RecoveryCodes.php` (NEW) | Recovery-codes modal | show/download/regenerate | | `resources/views/livewire/modals/recovery-codes.blade.php` (NEW) | Modal view | — | | `app/Livewire/Auth/RecoveryCodes.php` (DELETE) | Old full-page recovery | removed | | `resources/views/livewire/auth/recovery-codes.blade.php` (DELETE) | Old view | removed | | `routes/web.php` | Routes | remove `two-factor.recovery`, keep download | | `resources/views/livewire/auth/two-factor-challenge.blade.php` | Challenge UI | gate TOTP field | | `resources/views/livewire/settings/index.blade.php` | Security tab | modal trigger, hint, TOTP remove | | `resources/views/livewire/auth/forgot-password.blade.php` | Forgot copy | no-2FA note | | `resources/views/livewire/auth/two-factor-setup.blade.php` | Setup page | (works under app layout) | | `lang/{de,en}/auth.php`, `lang/{de,en}/settings.php` | Strings | new keys | --- ### Task 1: `User` factor semantics + recovery lifecycle **Files:** - Modify: `app/Models/User.php:85-98` - Test: `tests/Feature/UserFactorSemanticsTest.php` (Create) - [ ] **Step 1: Write the failing test** Create `tests/Feature/UserFactorSemanticsTest.php`: ```php create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]); } private function keyUser(): 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_has_totp_only_reflects_totp(): void { $this->assertTrue($this->totpUser()->hasTotp()); $this->assertFalse($this->keyUser()->hasTotp()); } public function test_has_two_factor_enabled_is_either_factor(): void { $this->assertTrue($this->totpUser()->hasTwoFactorEnabled()); $this->assertTrue($this->keyUser()->hasTwoFactorEnabled()); $this->assertFalse(User::factory()->create()->hasTwoFactorEnabled()); } public function test_security_onboarded_is_password_rotation_only(): void { $this->assertTrue(User::factory()->create(['must_change_password' => false])->securityOnboarded()); $this->assertFalse(User::factory()->create(['must_change_password' => true])->securityOnboarded()); } public function test_reset_if_no_factor_clears_codes_only_when_no_factor_remains(): void { $totp = $this->totpUser(); $totp->replaceRecoveryCodes(); $totp->resetIfNoFactor(); $this->assertTrue($totp->fresh()->hasRecoveryCodes(), 'codes kept while a factor remains'); $none = User::factory()->create(); $none->replaceRecoveryCodes(); $none->resetIfNoFactor(); $this->assertFalse($none->fresh()->hasRecoveryCodes(), 'codes cleared when no factor remains'); } } ``` - [ ] **Step 2: Run it to confirm it fails** Run: `docker compose exec -T app php artisan test --filter UserFactorSemanticsTest` Expected: FAIL — `hasTotp()` / `resetIfNoFactor()` undefined. - [ ] **Step 3: Implement the semantics** In `app/Models/User.php`, replace the `hasTwoFactorEnabled()` + `securityOnboarded()` block (lines 85-98) with: ```php /** TOTP factor only: an authenticator secret was confirmed. */ public function hasTotp(): bool { return ! is_null($this->two_factor_confirmed_at) && ! is_null($this->two_factor_secret); } /** 2FA is satisfied by EITHER factor — TOTP or a registered security key. */ public function hasTwoFactorEnabled(): bool { return $this->hasTotp() || $this->hasWebauthnCredentials(); } /** * Completed the security onboarding. 2FA is now OPTIONAL, so this is just the forced * password rotation — the gate EnsureSecurityOnboarded and the broadcast channels use. */ public function securityOnboarded(): bool { return ! $this->must_change_password; } /** * When neither factor remains (no TOTP, no keys), 2FA is fully off — so drop the backup * codes (nothing left to recover into). Call after removing any factor. */ public function resetIfNoFactor(): void { if (! $this->hasTotp() && ! $this->hasWebauthnCredentials()) { $this->forceFill(['two_factor_recovery_codes' => null])->save(); } } ``` - [ ] **Step 4: Run the test to confirm it passes** Run: `docker compose exec -T app php artisan test --filter UserFactorSemanticsTest` Expected: PASS (4 tests). - [ ] **Step 5: Commit** ```bash docker compose exec -T app ./vendor/bin/pint app/Models/User.php tests/Feature/UserFactorSemanticsTest.php git add app/Models/User.php tests/Feature/UserFactorSemanticsTest.php git commit -m "feat(2fa): pluggable factor semantics on User (hasTotp, either-factor, resetIfNoFactor)" ``` --- ### Task 2: Onboarding stops forcing 2FA **Files:** - Modify: `app/Http/Middleware/EnsureSecurityOnboarded.php:20-28` - Modify: `app/Livewire/Auth/PasswordChange.php:35-36` - Test: `tests/Feature/OptionalOnboardingTest.php` (Create) - [ ] **Step 1: Write the failing test** Create `tests/Feature/OptionalOnboardingTest.php`: ```php create(['must_change_password' => false]); $this->actingAs($user)->get('/')->assertOk(); } public function test_user_that_must_change_password_is_still_redirected(): void { $user = User::factory()->create(['must_change_password' => true]); $this->actingAs($user)->get('/')->assertRedirect(route('password.change')); } public function test_password_change_lands_on_dashboard_not_2fa_setup(): void { $user = User::factory()->create(['must_change_password' => true, 'password' => bcrypt('old-Password-1')]); Livewire::actingAs($user)->test(PasswordChange::class) ->set('current', 'old-Password-1') ->set('password', 'new-Password-123') ->set('password_confirmation', 'new-Password-123') ->call('update') ->assertRedirect(route('dashboard')); } public function test_broadcast_gate_passes_on_password_rotation_alone(): void { $user = User::factory()->create(['must_change_password' => false]); $this->assertTrue($user->securityOnboarded()); } } ``` - [ ] **Step 2: Run it to confirm it fails** Run: `docker compose exec -T app php artisan test --filter OptionalOnboardingTest` Expected: FAIL — `/` redirects an un-enrolled user to `two-factor.setup`; PasswordChange redirects to `two-factor.setup`. - [ ] **Step 3: Drop the 2FA redirect in the middleware** In `app/Http/Middleware/EnsureSecurityOnboarded.php`, replace the body of `handle()` (lines 17-31) with: ```php $user = $request->user(); if ($user && $user->must_change_password && ! $request->routeIs('password.change', 'logout')) { return redirect()->route('password.change'); } return $next($request); ``` Also update the class docblock (lines 9-12) to: ```php /** * After login, force the seeded-password rotation before the panel is reachable. * 2FA is optional (offered in Settings), so it is NOT forced here. */ ``` - [ ] **Step 4: Redirect PasswordChange to the dashboard** In `app/Livewire/Auth/PasswordChange.php`, replace lines 35-36: ```php // 2FA is optional now — go straight to the panel; 2FA is offered in Settings. return $this->redirect(route('dashboard'), navigate: true); ``` - [ ] **Step 5: Run the test to confirm it passes** Run: `docker compose exec -T app php artisan test --filter OptionalOnboardingTest` Expected: PASS (4 tests). - [ ] **Step 6: Commit** ```bash docker compose exec -T app ./vendor/bin/pint app/Http/Middleware/EnsureSecurityOnboarded.php app/Livewire/Auth/PasswordChange.php tests/Feature/OptionalOnboardingTest.php git add app/Http/Middleware/EnsureSecurityOnboarded.php app/Livewire/Auth/PasswordChange.php tests/Feature/OptionalOnboardingTest.php git commit -m "feat(2fa): onboarding no longer forces 2FA (only password rotation)" ``` --- ### Task 3: Recovery-codes MODAL (replace the full-page view) **Files:** - Create: `app/Livewire/Modals/RecoveryCodes.php` - Create: `resources/views/livewire/modals/recovery-codes.blade.php` - Delete: `app/Livewire/Auth/RecoveryCodes.php`, `resources/views/livewire/auth/recovery-codes.blade.php` - Modify: `routes/web.php:58` (remove `two-factor.recovery` route; keep download at 59-67) - Modify: `lang/de/auth.php`, `lang/en/auth.php` (modal title key) - Test: `tests/Feature/RecoveryCodesModalTest.php` (Create) - Delete test: `tests/Feature/RecoveryCodesViewTest.php` - [ ] **Step 1: Write the failing test** Create `tests/Feature/RecoveryCodesModalTest.php`: ```php create(); $codes = $user->replaceRecoveryCodes(); Livewire::actingAs($user)->test(RecoveryCodes::class) ->assertSee($codes[0]) ->assertSee($codes[7]); } public function test_regenerate_replaces_the_codes(): void { $user = User::factory()->create(); $old = $user->replaceRecoveryCodes(); Livewire::actingAs($user)->test(RecoveryCodes::class) ->call('regenerate') ->assertDontSee($old[0]); $this->assertNotEquals($old, $user->fresh()->recoveryCodes()); } public function test_old_recovery_route_is_gone(): void { $this->assertFalse(\Illuminate\Support\Facades\Route::has('two-factor.recovery')); $this->assertTrue(\Illuminate\Support\Facades\Route::has('two-factor.recovery.download')); } } ``` - [ ] **Step 2: Run it to confirm it fails** Run: `docker compose exec -T app php artisan test --filter RecoveryCodesModalTest` Expected: FAIL — `App\Livewire\Modals\RecoveryCodes` does not exist; `two-factor.recovery` still registered. - [ ] **Step 3: Create the modal component** Create `app/Livewire/Modals/RecoveryCodes.php`: ```php */ public function codes(): array { return Auth::user()->recoveryCodes(); } /** Regenerate the current user's backup codes (invalidates the old set). */ public function regenerate(): void { Auth::user()->replaceRecoveryCodes(); AuditEvent::create([ 'user_id' => Auth::id(), 'actor' => Auth::user()->name ?? 'system', 'action' => 'two_factor.recovery_regenerate', 'target' => Auth::user()->email, 'ip' => request()->ip(), ]); $this->dispatch('notify', message: __('auth.recovery_regenerated')); } public function render() { return view('livewire.modals.recovery-codes', ['codes' => $this->codes()]); } } ``` - [ ] **Step 4: Create the modal view** Create `resources/views/livewire/modals/recovery-codes.blade.php`: ```blade

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

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

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

@foreach ($codes as $c) {{ $c }} @endforeach
{{ __('auth.recovery_download') }} {{ __('auth.recovery_regenerate') }} {{ __('auth.recovery_done') }}
``` - [ ] **Step 5: Remove the old full-page route** In `routes/web.php`, delete line 58 (the `two-factor.recovery` GET route) and adjust the comment block (lines 56-57) to: ```php // 2FA backup-codes download — the codes themselves are shown in a modal (Modals\RecoveryCodes). ``` Keep the download route (lines 59-67) intact. - [ ] **Step 6: Delete the old full-page component, view, and its test** ```bash git rm app/Livewire/Auth/RecoveryCodes.php resources/views/livewire/auth/recovery-codes.blade.php tests/Feature/RecoveryCodesViewTest.php ``` - [ ] **Step 7: Add the modal title key (kept for parity; reuse existing recovery_* keys)** No new key is strictly required — the modal reuses `auth.recovery_heading/subtitle/warning/download/regenerate/done`. Verify `lang/de/auth.php` and `lang/en/auth.php` still contain them (they do, lines ~78-85). Remove the now-unused `auth.title_recovery` key from both files (it titled the deleted page): In `lang/de/auth.php` and `lang/en/auth.php`, delete the `'title_recovery' => …` line. - [ ] **Step 8: Run the test to confirm it passes** Run: `docker compose exec -T app php artisan test --filter RecoveryCodesModalTest` Expected: PASS (3 tests). - [ ] **Step 9: Commit** ```bash docker compose exec -T app ./vendor/bin/pint app/Livewire/Modals/RecoveryCodes.php tests/Feature/RecoveryCodesModalTest.php git add -A git commit -m "feat(2fa): recovery codes become a modal; drop the dedicated page + route" ``` --- ### Task 4: First-factor code generation + open the modal **Files:** - Modify: `app/Livewire/Auth/TwoFactorSetup.php` (layout, mount guard, confirm flow) - Modify: `app/Livewire/Settings/WebauthnKeys.php:28-47` (first-key codes) - Modify: `app/Livewire/Settings/Index.php` (mount reads the flash to open the modal) - Modify: `resources/views/livewire/settings/index.blade.php` (x-init opens modal on flash) - Test: `tests/Feature/FirstFactorCodesTest.php` (Create) - [ ] **Step 1: Write the failing test** Create `tests/Feature/FirstFactorCodesTest.php`: ```php create(); $g = new Google2FA; $secret = $g->generateSecretKey(); $component = Livewire::actingAs($user)->test(TwoFactorSetup::class); $component->set('secret', $secret) ->set('code', $g->getCurrentOtp($secret)) ->call('confirm') ->assertRedirect(route('settings')); $this->assertTrue($user->fresh()->hasRecoveryCodes()); $this->assertTrue(session('open_recovery_modal')); } public function test_first_key_register_generates_codes_and_opens_modal(): void { $user = User::factory()->create(); $svc = Mockery::mock(WebauthnService::class); $svc->shouldReceive('available')->andReturnTrue(); $svc->shouldReceive('verifyRegistration')->andReturnUsing(fn ($u, $a, $name) => WebauthnCredential::create([ 'user_id' => $u->id, 'name' => $name, 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0, ])); app()->instance(WebauthnService::class, $svc); Livewire::actingAs($user)->test(WebauthnKeys::class) ->set('newName', 'YubiKey 5C') ->call('register', ['dummy' => true], $svc) ->assertDispatched('openModal'); $this->assertTrue($user->fresh()->hasRecoveryCodes()); } } ``` - [ ] **Step 2: Run it to confirm it fails** Run: `docker compose exec -T app php artisan test --filter FirstFactorCodesTest` Expected: FAIL — TOTP confirm redirects to the removed `two-factor.recovery` route; key register does not generate codes nor dispatch `openModal`. - [ ] **Step 3: Rework TwoFactorSetup** In `app/Livewire/Auth/TwoFactorSetup.php`: Change the layout attribute (line 12) to: ```php #[Layout('layouts.app')] ``` Change `mount()` (lines 20-27) — guard on `hasTotp()` so a key-only user can still add TOTP: ```php public function mount() { if (Auth::user()->hasTotp()) { return $this->redirect(route('settings'), navigate: true); } $this->secret = (new Google2FA)->generateSecretKey(); } ``` Change `confirm()` (lines 29-46) — generate codes only if none exist, flash the modal flag, redirect to Settings: ```php public function confirm() { $this->validate(); if (! (new Google2FA)->verifyKey($this->secret, preg_replace('/\s+/', '', $this->code))) { throw ValidationException::withMessages(['code' => __('auth.code_mismatch')]); } Auth::user()->forceFill([ 'two_factor_secret' => $this->secret, 'two_factor_confirmed_at' => now(), ])->save(); // First factor enrolled and no codes yet → generate the single recovery set and // ask Settings to pop the modal (the app layout hosts wire-elements/modal). if (! Auth::user()->hasRecoveryCodes()) { Auth::user()->replaceRecoveryCodes(); session()->flash('open_recovery_modal', true); } return $this->redirect(route('settings'), navigate: true); } ``` - [ ] **Step 4: Generate codes on the first key in WebauthnKeys::register()** In `app/Livewire/Settings/WebauthnKeys.php`, inside `register()` after the credential is created (after line 35 `$this->reset('newName');`), insert: ```php // First factor enrolled and no codes yet → generate + show the recovery modal. if (! Auth::user()->hasRecoveryCodes()) { Auth::user()->replaceRecoveryCodes(); $this->dispatch('openModal', component: 'modals.recovery-codes'); } ``` - [ ] **Step 5: Settings opens the modal when the flash is set** In `app/Livewire/Settings/Index.php`, extend `mount()` (lines 28-32) to land on the security tab and expose the flash: ```php public bool $openRecoveryModal = false; public function mount(): void { $this->name = Auth::user()->name; $this->email = Auth::user()->email; if (session('open_recovery_modal')) { $this->tab = 'security'; $this->openRecoveryModal = true; } } ``` (Add the `public bool $openRecoveryModal = false;` property near the other public props, above `mount()`.) - [ ] **Step 6: The security tab dispatches openModal via x-init when flagged** In `resources/views/livewire/settings/index.blade.php`, immediately after the opening `
` (line 13), add: ```blade @if ($openRecoveryModal)
@endif ``` - [ ] **Step 7: Run the test to confirm it passes** Run: `docker compose exec -T app php artisan test --filter FirstFactorCodesTest` Expected: PASS (2 tests). - [ ] **Step 8: Commit** ```bash docker compose exec -T app ./vendor/bin/pint app/Livewire/Auth/TwoFactorSetup.php app/Livewire/Settings/WebauthnKeys.php app/Livewire/Settings/Index.php tests/Feature/FirstFactorCodesTest.php git add -A git commit -m "feat(2fa): generate recovery codes on the first factor + open the modal" ``` --- ### Task 5: Login challenge adapts to whichever factor exists **Files:** - Modify: `app/Livewire/Auth/TwoFactorChallenge.php:47-49` (verify) + add `hasTotp()` helper for the view - Modify: `resources/views/livewire/auth/two-factor-challenge.blade.php` (gate the TOTP field) - Test: `tests/Feature/ChallengeFactorAdaptTest.php` (Create) - [ ] **Step 1: Write the failing test** Create `tests/Feature/ChallengeFactorAdaptTest.php`: ```php create(); WebauthnCredential::create(['user_id' => $u->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); return $u->fresh(); } public function test_key_only_user_has_no_totp_field_but_a_backup_code_works(): void { $user = $this->keyOnlyUser(); $codes = $user->replaceRecoveryCodes(); session(['2fa.user' => $user->id, '2fa.remember' => false]); Livewire::test(TwoFactorChallenge::class) ->assertSet('pendingHasTotp', false) ->set('code', $codes[0]) ->call('verify') ->assertRedirect(route('dashboard')); $this->assertAuthenticatedAs($user); } 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); } } ``` - [ ] **Step 2: Run it to confirm it fails** Run: `docker compose exec -T app php artisan test --filter ChallengeFactorAdaptTest` Expected: FAIL — `pendingHasTotp` is not defined; `verify()` calls `Google2FA::verifyKey()` with a null secret for the key-only user (error/false before the backup-code fallback is reached safely). - [ ] **Step 3: Guard the TOTP check + expose the predicate** In `app/Livewire/Auth/TwoFactorChallenge.php`: Add a public computed property for the view. After `mount()` (line 26), add: ```php /** Whether the PENDING user has TOTP — the code field renders only then. */ public function getPendingHasTotpProperty(): bool { return (bool) User::find(session('2fa.user'))?->hasTotp(); } ``` Replace the TOTP-or-backup check (lines 47-49) with a guarded version: ```php // 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->hasTotp() && (new Google2FA)->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code))) || $user->useRecoveryCode($this->code); ``` > Note: Livewire exposes `getPendingHasTotpProperty()` to Blade as `$pendingHasTotp` and to `assertSet('pendingHasTotp', …)`. - [ ] **Step 4: Gate the TOTP field in the view** In `resources/views/livewire/auth/two-factor-challenge.blade.php`, wrap the code ``/label block in `@if ($this->pendingHasTotp) … @endif`, and make the backup-code hint always available. Read the file first; the field is the `wire:model="code"` input group. Replace its surrounding block with: ```blade @if ($this->pendingHasTotp) {{-- existing TOTP code label + input group, unchanged --}} @endif ``` Keeping: the "Mit Security-Key anmelden" button (already gated by `$this->webauthnAvailable()`), and the backup-code hint (`auth.challenge_recovery_hint`). For a key-only user the `code` input must still be reachable to type a backup code — if the only input was inside the `@if`, add a single shared code input shown when `! $this->pendingHasTotp` too, labelled with `auth.challenge_recovery_hint`. Concretely, structure the form as: ```blade
@error('code')

{{ $message }}

@enderror
``` > The single `code` input serves TOTP for a TOTP user and a backup code for a key-only user; `verify()` already tries both paths. Match the existing `$label`/`$field`/`$err` locals at the top of the view (read it; they mirror the login view). If those locals are absent, reuse the classes already present on the existing input. - [ ] **Step 5: Add the new challenge label keys (de + en)** In `lang/de/auth.php` add (near the other `challenge_*` keys): ```php 'challenge_code_label' => 'Authenticator-Code', 'challenge_code_placeholder' => '123456', 'challenge_backup_label' => 'Backup-Code', 'challenge_backup_placeholder' => 'xxxxxxxxxx-xxxxxxxxxx', ``` In `lang/en/auth.php` add the same keys: ```php 'challenge_code_label' => 'Authenticator code', 'challenge_code_placeholder' => '123456', 'challenge_backup_label' => 'Backup code', 'challenge_backup_placeholder' => 'xxxxxxxxxx-xxxxxxxxxx', ``` (If `challenge_code_label`/`challenge_code_placeholder` already exist in the file, reuse them and only add the two `challenge_backup_*` keys.) - [ ] **Step 6: Run the test to confirm it passes** Run: `docker compose exec -T app php artisan test --filter ChallengeFactorAdaptTest` Expected: PASS (2 tests). - [ ] **Step 7: Commit** ```bash docker compose exec -T app ./vendor/bin/pint app/Livewire/Auth/TwoFactorChallenge.php tests/Feature/ChallengeFactorAdaptTest.php git add -A git commit -m "feat(2fa): challenge adapts to factor — TOTP field gated, key-only uses backup code" ``` --- ### Task 6: Settings — manage factors independently **Files:** - Modify: `app/Livewire/Settings/Index.php:77-90` (disableTwoFactor → remove TOTP + reset) - Modify: `app/Livewire/Settings/WebauthnKeys.php:20,32,97` (gate on `available()` only; last-key reset) - Modify: `resources/views/livewire/settings/index.blade.php` (TOTP card always removable, "empfohlen" hint, recovery modal trigger) - Modify: `lang/{de,en}/settings.php` (hint + TOTP-remove keys) - Test: `tests/Feature/SettingsFactorManagementTest.php` (Create) - [ ] **Step 1: Write the failing test** Create `tests/Feature/SettingsFactorManagementTest.php`: ```php create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]); $user->replaceRecoveryCodes(); Livewire::actingAs($user)->test(Index::class)->call('disableTwoFactor'); $fresh = $user->fresh(); $this->assertFalse($fresh->hasTotp()); $this->assertFalse($fresh->hasRecoveryCodes(), 'last factor removed → codes cleared'); } public function test_removing_totp_keeps_codes_when_a_key_remains(): void { $user = User::factory()->create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]); WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); $user->replaceRecoveryCodes(); Livewire::actingAs($user->fresh())->test(Index::class)->call('disableTwoFactor'); $fresh = $user->fresh(); $this->assertFalse($fresh->hasTotp()); $this->assertTrue($fresh->hasWebauthnCredentials()); $this->assertTrue($fresh->hasRecoveryCodes(), 'key remains → codes kept'); } public function test_a_key_can_be_the_first_factor_without_existing_2fa(): void { $user = User::factory()->create(); // no TOTP, no key $svc = Mockery::mock(WebauthnService::class); $svc->shouldReceive('available')->andReturnTrue(); $svc->shouldReceive('registrationOptions')->andReturn(['ok' => true]); app()->instance(WebauthnService::class, $svc); // options() must NOT 404 just because the user has no 2FA yet. Livewire::actingAs($user)->test(WebauthnKeys::class) ->set('newName', 'YubiKey') ->call('options', $svc) ->assertReturned(['ok' => true]); } public function test_removing_the_last_key_clears_codes(): void { $user = User::factory()->create(); $cred = WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); $user->replaceRecoveryCodes(); $svc = Mockery::mock(WebauthnService::class); $svc->shouldReceive('available')->andReturnTrue(); app()->instance(WebauthnService::class, $svc); Livewire::actingAs($user->fresh())->test(WebauthnKeys::class)->call('remove', $cred->id, $svc); $this->assertFalse($user->fresh()->hasRecoveryCodes()); } } ``` - [ ] **Step 2: Run it to confirm it fails** Run: `docker compose exec -T app php artisan test --filter SettingsFactorManagementTest` Expected: FAIL — `disableTwoFactor` nukes keys + does not reset codes conditionally; `WebauthnKeys::options()`/`register()` 404 without `hasTwoFactorEnabled()`; `remove()` does not reset codes. - [ ] **Step 3: Reframe `disableTwoFactor` to remove only TOTP** In `app/Livewire/Settings/Index.php`, replace `disableTwoFactor()` (lines 77-90) with: ```php #[On('twoFactorDisabled')] public function disableTwoFactor(): void { // Remove the TOTP factor only — security keys are managed on their own card. If no // factor remains afterwards, the backup codes are dropped too. Auth::user()->forceFill([ 'two_factor_secret' => null, 'two_factor_confirmed_at' => null, ])->save(); Auth::user()->resetIfNoFactor(); } ``` - [ ] **Step 4: Gate WebauthnKeys on `available()` only + reset on last-key removal** In `app/Livewire/Settings/WebauthnKeys.php`: `options()` line 20 — drop the 2FA requirement: ```php abort_unless($webauthn->available(), 404); ``` `register()` line 32 — same: ```php abort_unless($webauthn->available(), 404); ``` `remove()` — after the credential is deleted and audited (after line 90 `$this->dispatch('notify', …)`), add the reset: ```php Auth::user()->resetIfNoFactor(); ``` `render()` line 97 — the key card is available purely on the service gate now: ```php 'available' => app(WebauthnService::class)->available(), ``` - [ ] **Step 5: Settings security tab — TOTP always removable, hint, modal trigger** In `resources/views/livewire/settings/index.blade.php`, replace the 2FA panel block (lines 99-119) with: ```blade @unless ($twoFactorEnabled)

{{ __('settings.twofa_recommended') }}

@endunless

{{ $hasTotp ? __('settings.twofa_status_on') : __('settings.twofa_status_off') }}

{{ $hasTotp ? __('settings.twofa_hint_on') : __('settings.twofa_hint_off') }}

@if ($twoFactorEnabled) {{ __('auth.recovery_manage') }} @endif @if ($hasTotp) {{ __('settings.twofa_remove_totp') }} @else {{ __('settings.twofa_setup') }} @endif
``` - [ ] **Step 6: Pass `hasTotp` into the Settings view** In `app/Livewire/Settings/Index.php`, `render()` — add `hasTotp` alongside `twoFactorEnabled`: ```php return view('livewire.settings.index', [ 'twoFactorEnabled' => Auth::user()->hasTwoFactorEnabled(), 'hasTotp' => Auth::user()->hasTotp(), ])->title(__('settings.title')); ``` - [ ] **Step 7: Add the new settings keys (de + en)** In `lang/de/settings.php` add: ```php 'twofa_recommended' => '2FA ist optional, aber empfohlen — sichere dein Konto mit einem Authenticator oder Security-Key.', 'twofa_remove_totp' => 'Authenticator entfernen', ``` In `lang/en/settings.php` add: ```php 'twofa_recommended' => '2FA is optional but recommended — secure your account with an authenticator or a security key.', 'twofa_remove_totp' => 'Remove authenticator', ``` - [ ] **Step 8: Run the test to confirm it passes** Run: `docker compose exec -T app php artisan test --filter SettingsFactorManagementTest` Expected: PASS (4 tests). - [ ] **Step 9: Update the disable-2FA confirm copy (it no longer nukes keys)** In `lang/de/settings.php` and `lang/en/settings.php`, soften `disable_2fa_heading`/`disable_2fa_body`/`disable_2fa_notify` to refer to the **authenticator** only (not "2FA"), e.g. DE `disable_2fa_heading` → `'Authenticator entfernen'`, `disable_2fa_body` → `'Den Authenticator (TOTP) als Faktor entfernen? Security-Keys bleiben bestehen.'`. Mirror in EN. (Read both files; keep keys, change values.) - [ ] **Step 10: Commit** ```bash docker compose exec -T app ./vendor/bin/pint app/Livewire/Settings/Index.php app/Livewire/Settings/WebauthnKeys.php tests/Feature/SettingsFactorManagementTest.php git add -A git commit -m "feat(2fa): Settings manages TOTP + keys independently; key can be the only factor; empfohlen hint" ``` --- ### Task 7: forgot-password — key-only safe + no-2FA copy **Files:** - Modify: `app/Livewire/Auth/ForgotPassword.php:66-68` (guard verifyKey on hasTotp) - Modify: `resources/views/livewire/auth/forgot-password.blade.php` (no-2FA note) - Modify: `lang/{de,en}/auth.php` (no-2FA reset note) - Test: `tests/Feature/ForgotPasswordKeyOnlyTest.php` (Create) - [ ] **Step 1: Write the failing test** Create `tests/Feature/ForgotPasswordKeyOnlyTest.php`: ```php create(['must_change_password' => false]); WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); $codes = $user->fresh()->replaceRecoveryCodes(); Livewire::test(ForgotPassword::class) ->set('email', $user->email) ->set('code', $codes[0]) ->set('password', 'brand-New-Pass-9') ->set('password_confirmation', 'brand-New-Pass-9') ->call('resetPassword') ->assertRedirect(route('login')); $this->assertTrue(\Illuminate\Support\Facades\Hash::check('brand-New-Pass-9', $user->fresh()->password)); } } ``` - [ ] **Step 2: Run it to confirm it fails** Run: `docker compose exec -T app php artisan test --filter ForgotPasswordKeyOnlyTest` Expected: FAIL — `Google2FA::verifyKey()` is called with the key-only user's null `two_factor_secret` before the backup-code fallback, throwing or short-circuiting. - [ ] **Step 3: Guard the TOTP check** In `app/Livewire/Auth/ForgotPassword.php`, replace the `$ok` assignment (lines 66-68) with: ```php $ok = $user && $user->hasTwoFactorEnabled() && (($user->hasTotp() && (new Google2FA)->verifyKey($user->two_factor_secret, $clean)) || $user->useRecoveryCode($this->code)); ``` - [ ] **Step 4: Add the no-2FA reset note to the view + lang** In `resources/views/livewire/auth/forgot-password.blade.php`, add a small hint near the form footer: ```blade

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

``` In `lang/de/auth.php`: ```php 'reset_no_2fa_note' => 'Ohne 2FA ist die Wiederherstellung nur per E-Mail-Link (falls SMTP konfiguriert) oder über den Befehl clusev:reset-admin möglich.', ``` In `lang/en/auth.php`: ```php 'reset_no_2fa_note' => 'Without 2FA, recovery is only possible via the email link (if SMTP is configured) or the clusev:reset-admin command.', ``` - [ ] **Step 5: Run the test to confirm it passes** Run: `docker compose exec -T app php artisan test --filter ForgotPasswordKeyOnlyTest` Expected: PASS. - [ ] **Step 6: Commit** ```bash docker compose exec -T app ./vendor/bin/pint app/Livewire/Auth/ForgotPassword.php tests/Feature/ForgotPasswordKeyOnlyTest.php git add -A git commit -m "feat(2fa): forgot-password is key-only safe + states the no-2FA recovery path" ``` --- ### Task 8: Regression sweep, R12 browser verify, Codex, release **Files:** - Modify (as needed): the existing 2FA tests whose assumptions changed - Modify: `config/clusev.php:6` (version bump) - Modify: `CHANGELOG.md` - [ ] **Step 1: Run the full suite and fix fallout** Run: `docker compose exec -T app php artisan test` Expected: green. Likely files needing small updates because semantics changed: - `tests/Feature/WebauthnKeysTest.php`, `tests/Feature/WebauthnOptionsTest.php`, `tests/Feature/WebauthnAvailableTest.php`: any test that set up `two_factor_confirmed_at`/secret purely to satisfy the old `hasTwoFactorEnabled()` gate on `options()`/`register()` can drop that setup (the gate is now `available()` only). Where a test asserted a 404 for a key attempt without TOTP, invert it to expect success. - `tests/Feature/TwoFactorChallengeRecoveryTest.php`, `tests/Feature/TwoFactorWebauthnTest.php`: should still pass; if any asserts the challenge view contains the TOTP field for a key-only user, update to the new gated behaviour. - Fix each failure by reading the test and aligning it with the spec (TOTP-only field, either-factor `hasTwoFactorEnabled`, codes lifecycle). Do not weaken security assertions. - [ ] **Step 2: Build assets** Run: `docker compose run --rm --no-deps -u "${HOST_UID}:${HOST_GID}" app npm run build` Expected: `✓ built`. - [ ] **Step 3: R12 browser verification (bare-IP, DE + EN)** With a connected browser at `http://10.10.90.136`, verify at 375/768/1280: - Login as the admin → after password rotation a no-2FA account reaches the **dashboard** (no forced 2FA). - `/settings` → Security tab: the **"2FA empfohlen"** hint shows; the **TOTP card** offers *Einrichten*; the **Security-Keys** card shows the domain-unavailable hint on bare IP. - Enroll TOTP at `/two-factor-setup` → on confirm you land on **Settings** and the **recovery-codes modal** pops with 8 codes + Download + Neu erzeugen + Gespeichert. - After enrolling, the TOTP card shows *Authenticator entfernen*; **Backup-Codes verwalten** opens the modal. - Remove the authenticator → codes cleared, hint returns. - Switch locale (DE↔EN) and re-check the same screens for leaked `group.key`/`@`/`{{ }}` tokens (R17) and HTTP 200 + zero console errors. - Log out, log in again with TOTP → the **challenge** shows the Authenticator field; entering a backup code also works. Use the browser tools to confirm zero console errors on each touched route. - [ ] **Step 4: Codex review (R15)** Run a Codex review over the full diff (`git diff main...HEAD` or the working branch range). Fix every P1 and security finding; re-run until clean. - [ ] **Step 5: Version bump + CHANGELOG** In `config/clusev.php` line 6, bump `'version' => '0.7.0'` (new feature). Add a `## v0.7.0` section to `CHANGELOG.md` summarizing: optional/pluggable 2FA (TOTP and/or WebAuthn, or off), recovery codes as a modal, onboarding no longer forces 2FA. - [ ] **Step 6: Final suite + commit + finish** ```bash docker compose exec -T app php artisan test docker compose exec -T app ./vendor/bin/pint git add -A git commit -m "release: optional pluggable 2FA (v0.7.0)" ``` Then invoke `superpowers:finishing-a-development-branch` to complete the work. --- ## Self-Review **Spec coverage:** - §1 Factor semantics → Task 1 (`hasTotp`, either-factor `hasTwoFactorEnabled`, `securityOnboarded` = `!must_change_password`, `resetIfNoFactor`). Callers using `securityOnboarded()`/`hasTwoFactorEnabled()` (`routes/channels.php`, Login) need no edit — they call the methods whose behaviour changed; Login routing to the challenge for a key-only user is correct (Task 5 makes the challenge usable). ✓ - §2 Onboarding not forced → Task 2 (middleware + PasswordChange → dashboard; broadcast gate). ✓ - §3 Recovery codes modal → Task 3 (new modal + view, delete page/view/route, keep download) + Task 4 (generate on first factor, open modal) + Task 6 (Settings "verwalten" → modal). ✓ - §4 Challenge adapts → Task 5 (verify guarded, field gated, key-only backup code). ✓ - §5 Settings manage factors → Task 6 (TOTP removable always + reset, keys gated on `available()` only + last-key reset, "empfohlen" hint, disable copy reframed). ✓ - §6 forgot-password → Task 7 (key-only safe, no-2FA note). ✓ - Files-touched + Testing sections → covered across Tasks 1-8; R12/Codex in Task 8. ✓ **Placeholder scan:** No "TBD"/"handle edge cases"; the one structural Blade edit (challenge field) gives the concrete replacement markup. The "existing 2FA tests" updates in Task 8 are inherently read-then-fix but bounded with explicit guidance per file. ✓ **Type consistency:** Method names consistent across tasks — `hasTotp()`, `hasTwoFactorEnabled()`, `securityOnboarded()`, `resetIfNoFactor()`, `hasRecoveryCodes()`, `replaceRecoveryCodes()`, `recoveryCodes()`. Modal component `App\Livewire\Modals\RecoveryCodes` ↔ dispatch name `modals.recovery-codes` ↔ view `livewire.modals.recovery-codes`. Flash key `open_recovery_modal` set in Task 4 (TwoFactorSetup) and read in Task 4 (Settings). View locals `hasTotp`/`twoFactorEnabled` defined in Task 6 `render()` and used in the Task 6 blade. ✓