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
parent
6a4ba16480
commit
c19a35e43d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue