feat(2fa): generate recovery codes on first factor + open modal; persist Settings tab in URL

- TwoFactorSetup: move to app layout (hosts wire-elements/modal), guard on hasTotp() not hasTwoFactorEnabled(), redirect to settings and flash open_recovery_modal on first TOTP enrollment
- WebauthnKeys.register(): drop hasTwoFactorEnabled() guard (first key may be the sole factor), generate codes + dispatch openModal on first key enrollment
- Settings\Index: add #[Url] on $tab, add $openRecoveryModal flag, mount() reads the flash and lands on the security tab
- settings/index.blade.php: x-init block dispatches openModal when $openRecoveryModal is set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 21:04:06 +02:00
parent 6a4ba16480
commit c19a35e43d
5 changed files with 93 additions and 9 deletions

View File

@ -9,7 +9,7 @@ use Livewire\Attributes\Validate;
use Livewire\Component;
use PragmaRX\Google2FAQRCode\Google2FA;
#[Layout('layouts.auth')]
#[Layout('layouts.app')]
class TwoFactorSetup extends Component
{
public string $secret = '';
@ -19,8 +19,8 @@ class TwoFactorSetup extends Component
public function mount()
{
if (Auth::user()->hasTwoFactorEnabled()) {
return $this->redirect(route('dashboard'), navigate: true);
if (Auth::user()->hasTotp()) {
return $this->redirect(route('settings'), navigate: true);
}
$this->secret = (new Google2FA)->generateSecretKey();
@ -39,10 +39,14 @@ class TwoFactorSetup extends Component
'two_factor_confirmed_at' => now(),
])->save();
// Generate backup codes and show them once before entering the panel.
Auth::user()->replaceRecoveryCodes();
// 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('two-factor.recovery'), navigate: true);
return $this->redirect(route('settings'), navigate: true);
}
public function qrCode(): string

View File

@ -8,13 +8,17 @@ use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
#[Url]
public string $tab = 'profile';
public bool $openRecoveryModal = false;
public string $name = '';
public string $email = '';
@ -29,6 +33,11 @@ class Index extends Component
{
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
if (session('open_recovery_modal')) {
$this->tab = 'security';
$this->openRecoveryModal = true;
}
}
public function updateProfile(): void
@ -104,6 +113,7 @@ class Index extends Component
{
return view('livewire.settings.index', [
'twoFactorEnabled' => Auth::user()->hasTwoFactorEnabled(),
'hasTotp' => Auth::user()->hasTotp(),
])->title(__('settings.title'));
}
}

View File

@ -27,14 +27,21 @@ class WebauthnKeys extends Component
public function register(array $attestation, WebauthnService $webauthn): void
{
// Re-check 2FA enrollment (it may have been disabled in another tab since the
// ceremony started) so a key is never created against a reset second factor.
abort_unless($webauthn->available() && Auth::user()->hasTwoFactorEnabled(), 404);
// WebAuthn must be available; no existing-factor guard here — registering a key
// may itself be the FIRST factor enrollment (the guard in options() already
// vetted the ceremony start; only require availability at submission time).
abort_unless($webauthn->available(), 404);
$this->validate();
$cred = $webauthn->verifyRegistration(Auth::user(), $attestation, trim($this->newName));
$this->reset('newName');
// 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');
}
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name ?? 'system',

View File

@ -11,6 +11,9 @@
@endphp
<div class="space-y-5">
@if ($openRecoveryModal)
<div x-data x-init="$dispatch('openModal', { component: 'modals.recovery-codes' })"></div>
@endif
{{-- Identity header --}}
<div class="flex flex-wrap items-center gap-4 rounded-xl border border-line bg-surface p-5 shadow-panel">
<span class="grid h-14 w-14 shrink-0 place-items-center rounded-xl border border-accent/25 bg-accent/10 font-display text-xl font-semibold text-accent">{{ $initials }}</span>

View File

@ -0,0 +1,60 @@
<?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();
Livewire::actingAs($user)->test(TwoFactorSetup::class)
->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());
}
}