From 1273d8290d51fc3eda27c07adc92b4a19bb05396 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 20:20:37 +0200 Subject: [PATCH] docs: implementation plan for optional pluggable 2FA 8 TDD tasks covering the approved spec: pluggable factor semantics, optional onboarding, recovery-codes modal, factor-adaptive login challenge, independent TOTP/key management in Settings, key-only-safe forgot-password, plus the R12 + Codex + release sweep. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-14-optional-pluggable-2fa.md | 1224 +++++++++++++++++ 1 file changed, 1224 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-14-optional-pluggable-2fa.md diff --git a/docs/superpowers/plans/2026-06-14-optional-pluggable-2fa.md b/docs/superpowers/plans/2026-06-14-optional-pluggable-2fa.md new file mode 100644 index 0000000..f3f1cfc --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-optional-pluggable-2fa.md @@ -0,0 +1,1224 @@ +# 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. ✓