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

48 KiB

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

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:

    /** 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
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

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:

        $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:

/**
 * 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:

        // 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
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

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

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:

<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:

    // 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
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
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

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:

#[Layout('layouts.app')]

Change mount() (lines 20-27) — guard on hasTotp() so a key-only user can still add TOTP:

    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:

    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:

        // 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:

    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:

    @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
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

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:

    /** 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:

        // 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:

        @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:

        <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):

    '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:

    '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
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

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:

    #[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:

        abort_unless($webauthn->available(), 404);

register() line 32 — same:

        abort_unless($webauthn->available(), 404);

remove() — after the credential is deleted and audited (after line 90 $this->dispatch('notify', …)), add the reset:

            Auth::user()->resetIfNoFactor();

render() line 97 — the key card is available purely on the service gate now:

            '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:

                <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:

        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:

    '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:

    '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
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

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:

        $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:

        <p class="font-mono text-[11px] text-ink-4">{{ __('auth.reset_no_2fa_note') }}</p>

In lang/de/auth.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:

    '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
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
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. ✓