clusev/docs/superpowers/plans/2026-06-14-optional-pluggab...

1225 lines
48 KiB
Markdown

# 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 <Name>`, `docker compose exec -T app ./vendor/bin/pint <files>`.
- 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
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\WebauthnCredential;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UserFactorSemanticsTest extends TestCase
{
use RefreshDatabase;
private function totpUser(): User
{
return User::factory()->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
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use App\Livewire\Auth\PasswordChange;
use Tests\TestCase;
class OptionalOnboardingTest extends TestCase
{
use RefreshDatabase;
public function test_rotated_user_without_2fa_reaches_the_dashboard(): void
{
$user = User::factory()->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
<?php
namespace Tests\Feature;
use App\Livewire\Modals\RecoveryCodes;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class RecoveryCodesModalTest extends TestCase
{
use RefreshDatabase;
public function test_modal_lists_the_current_codes(): void
{
$user = User::factory()->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
<?php
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
/**
* Backup (recovery) codes, shown once in a modal. Opened on first-factor enrollment and
* from Settings → Security ("Backup-Codes verwalten"). The download endpoint stays a route.
*/
class RecoveryCodes extends ModalComponent
{
public static function modalMaxWidth(): string
{
return 'lg';
}
/** @return array<int, string> */
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
<div class="p-5 sm:p-6">
<div class="flex items-start gap-3.5">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-accent/30 bg-accent/10 text-accent-text">
<x-icon name="shield" class="h-5 w-5" />
</span>
<div class="min-w-0 flex-1">
<h2 class="font-display text-base font-semibold text-ink">{{ __('auth.recovery_heading') }}</h2>
<p class="mt-1 text-sm leading-relaxed text-ink-2">{{ __('auth.recovery_subtitle') }}</p>
</div>
</div>
<div class="mt-4 flex items-start gap-2.5 rounded-md border border-warning/25 bg-warning/10 px-4 py-3">
<x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p class="text-sm text-ink-2">{{ __('auth.recovery_warning') }}</p>
</div>
<div class="mt-4 grid grid-cols-1 gap-2 rounded-md border border-line bg-inset p-4 sm:grid-cols-2">
@foreach ($codes as $c)
<span class="font-mono text-sm tracking-wide text-ink">{{ $c }}</span>
@endforeach
</div>
<div class="mt-6 flex flex-wrap items-center gap-2">
<x-btn href="{{ route('two-factor.recovery.download') }}" variant="secondary">
<x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('auth.recovery_download') }}
</x-btn>
<x-btn variant="secondary" wire:click="regenerate" wire:target="regenerate" wire:loading.attr="disabled">
<x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('auth.recovery_regenerate') }}
</x-btn>
<x-btn variant="primary" class="ml-auto" wire:click="$dispatch('closeModal')">{{ __('auth.recovery_done') }}</x-btn>
</div>
</div>
```
- [ ] **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
<?php
namespace Tests\Feature;
use App\Livewire\Auth\TwoFactorSetup;
use App\Livewire\Settings\WebauthnKeys;
use App\Models\User;
use App\Models\WebauthnCredential;
use App\Services\WebauthnService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use PragmaRX\Google2FAQRCode\Google2FA;
use Tests\TestCase;
class FirstFactorCodesTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_first_totp_confirm_generates_codes_and_opens_modal(): void
{
$user = User::factory()->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 `<div class="space-y-5">` (line 13), add:
```blade
@if ($openRecoveryModal)
<div x-data x-init="$dispatch('openModal', { component: 'modals.recovery-codes' })"></div>
@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
<?php
namespace Tests\Feature;
use App\Livewire\Auth\TwoFactorChallenge;
use App\Models\User;
use App\Models\WebauthnCredential;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class ChallengeFactorAdaptTest extends TestCase
{
use RefreshDatabase;
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_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 `<input>`/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
<div>
<label class="{{ $label }}">
{{ $this->pendingHasTotp ? __('auth.challenge_code_label') : __('auth.challenge_backup_label') }}
</label>
<input wire:model="code" inputmode="numeric" autocomplete="one-time-code" autofocus
placeholder="{{ $this->pendingHasTotp ? __('auth.challenge_code_placeholder') : __('auth.challenge_backup_placeholder') }}"
class="{{ $field }} font-mono tracking-[0.3em]" />
@error('code') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
```
> 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
<?php
namespace Tests\Feature;
use App\Livewire\Settings\Index;
use App\Livewire\Settings\WebauthnKeys;
use App\Models\User;
use App\Models\WebauthnCredential;
use App\Services\WebauthnService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
class SettingsFactorManagementTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_removing_totp_when_it_is_the_only_factor_clears_codes(): void
{
$user = User::factory()->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
<x-panel :title="__('settings.twofa_title')" :subtitle="__('settings.twofa_subtitle')">
@unless ($twoFactorEnabled)
<div class="mb-4 flex items-start gap-2.5 rounded-md border border-accent/25 bg-accent/10 px-4 py-3">
<x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-accent-text" />
<p class="text-sm text-ink-2">{{ __('settings.twofa_recommended') }}</p>
</div>
@endunless
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<x-status-dot :status="$hasTotp ? 'online' : 'offline'" />
<div>
<p class="text-sm text-ink">{{ $hasTotp ? __('settings.twofa_status_on') : __('settings.twofa_status_off') }}</p>
<p class="font-mono text-[11px] text-ink-3">{{ $hasTotp ? __('settings.twofa_hint_on') : __('settings.twofa_hint_off') }}</p>
</div>
</div>
<div class="flex items-center gap-2">
@if ($twoFactorEnabled)
<x-btn variant="secondary" wire:click="$dispatch('openModal', { component: 'modals.recovery-codes' })">{{ __('auth.recovery_manage') }}</x-btn>
@endif
@if ($hasTotp)
<x-btn variant="danger-soft" wire:click="confirmDisableTwoFactor">{{ __('settings.twofa_remove_totp') }}</x-btn>
@else
<x-btn variant="accent" :href="route('two-factor.setup')" wire:navigate>
<x-icon name="shield" class="h-3.5 w-3.5" /> {{ __('settings.twofa_setup') }}
</x-btn>
@endif
</div>
</div>
</x-panel>
<livewire:settings.webauthn-keys />
```
- [ ] **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
<?php
namespace Tests\Feature;
use App\Livewire\Auth\ForgotPassword;
use App\Models\User;
use App\Models\WebauthnCredential;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class ForgotPasswordKeyOnlyTest extends TestCase
{
use RefreshDatabase;
public function test_key_only_user_resets_with_a_backup_code(): void
{
$user = User::factory()->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
<p class="font-mono text-[11px] text-ink-4">{{ __('auth.reset_no_2fa_note') }}</p>
```
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. ✓