diff --git a/app/Livewire/Auth/TwoFactorSetup.php b/app/Livewire/Auth/TwoFactorSetup.php index 8452e58..d030f5f 100644 --- a/app/Livewire/Auth/TwoFactorSetup.php +++ b/app/Livewire/Auth/TwoFactorSetup.php @@ -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 diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index ae4267a..35110cc 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -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')); } } diff --git a/app/Livewire/Settings/WebauthnKeys.php b/app/Livewire/Settings/WebauthnKeys.php index acdc74f..5edf1cf 100644 --- a/app/Livewire/Settings/WebauthnKeys.php +++ b/app/Livewire/Settings/WebauthnKeys.php @@ -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', diff --git a/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php index a277b13..cd081af 100644 --- a/resources/views/livewire/settings/index.blade.php +++ b/resources/views/livewire/settings/index.blade.php @@ -11,6 +11,9 @@ @endphp
+ @if ($openRecoveryModal) +
+ @endif {{-- Identity header --}}
{{ $initials }} diff --git a/tests/Feature/FirstFactorCodesTest.php b/tests/Feature/FirstFactorCodesTest.php new file mode 100644 index 0000000..e47d9e3 --- /dev/null +++ b/tests/Feature/FirstFactorCodesTest.php @@ -0,0 +1,60 @@ +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()); + } +}