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 Livewire\Component;
|
||||||
use PragmaRX\Google2FAQRCode\Google2FA;
|
use PragmaRX\Google2FAQRCode\Google2FA;
|
||||||
|
|
||||||
#[Layout('layouts.auth')]
|
#[Layout('layouts.app')]
|
||||||
class TwoFactorSetup extends Component
|
class TwoFactorSetup extends Component
|
||||||
{
|
{
|
||||||
public string $secret = '';
|
public string $secret = '';
|
||||||
|
|
@ -19,8 +19,8 @@ class TwoFactorSetup extends Component
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
if (Auth::user()->hasTwoFactorEnabled()) {
|
if (Auth::user()->hasTotp()) {
|
||||||
return $this->redirect(route('dashboard'), navigate: true);
|
return $this->redirect(route('settings'), navigate: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->secret = (new Google2FA)->generateSecretKey();
|
$this->secret = (new Google2FA)->generateSecretKey();
|
||||||
|
|
@ -39,10 +39,14 @@ class TwoFactorSetup extends Component
|
||||||
'two_factor_confirmed_at' => now(),
|
'two_factor_confirmed_at' => now(),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
// Generate backup codes and show them once before entering the panel.
|
// First factor enrolled and no codes yet → generate the single recovery set and
|
||||||
Auth::user()->replaceRecoveryCodes();
|
// 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
|
public function qrCode(): string
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,17 @@ use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
#[Layout('layouts.app')]
|
#[Layout('layouts.app')]
|
||||||
class Index extends Component
|
class Index extends Component
|
||||||
{
|
{
|
||||||
|
#[Url]
|
||||||
public string $tab = 'profile';
|
public string $tab = 'profile';
|
||||||
|
|
||||||
|
public bool $openRecoveryModal = false;
|
||||||
|
|
||||||
public string $name = '';
|
public string $name = '';
|
||||||
|
|
||||||
public string $email = '';
|
public string $email = '';
|
||||||
|
|
@ -29,6 +33,11 @@ class Index extends Component
|
||||||
{
|
{
|
||||||
$this->name = Auth::user()->name;
|
$this->name = Auth::user()->name;
|
||||||
$this->email = Auth::user()->email;
|
$this->email = Auth::user()->email;
|
||||||
|
|
||||||
|
if (session('open_recovery_modal')) {
|
||||||
|
$this->tab = 'security';
|
||||||
|
$this->openRecoveryModal = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateProfile(): void
|
public function updateProfile(): void
|
||||||
|
|
@ -104,6 +113,7 @@ class Index extends Component
|
||||||
{
|
{
|
||||||
return view('livewire.settings.index', [
|
return view('livewire.settings.index', [
|
||||||
'twoFactorEnabled' => Auth::user()->hasTwoFactorEnabled(),
|
'twoFactorEnabled' => Auth::user()->hasTwoFactorEnabled(),
|
||||||
|
'hasTotp' => Auth::user()->hasTotp(),
|
||||||
])->title(__('settings.title'));
|
])->title(__('settings.title'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,14 +27,21 @@ class WebauthnKeys extends Component
|
||||||
|
|
||||||
public function register(array $attestation, WebauthnService $webauthn): void
|
public function register(array $attestation, WebauthnService $webauthn): void
|
||||||
{
|
{
|
||||||
// Re-check 2FA enrollment (it may have been disabled in another tab since the
|
// WebAuthn must be available; no existing-factor guard here — registering a key
|
||||||
// ceremony started) so a key is never created against a reset second factor.
|
// may itself be the FIRST factor enrollment (the guard in options() already
|
||||||
abort_unless($webauthn->available() && Auth::user()->hasTwoFactorEnabled(), 404);
|
// vetted the ceremony start; only require availability at submission time).
|
||||||
|
abort_unless($webauthn->available(), 404);
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
||||||
$cred = $webauthn->verifyRegistration(Auth::user(), $attestation, trim($this->newName));
|
$cred = $webauthn->verifyRegistration(Auth::user(), $attestation, trim($this->newName));
|
||||||
$this->reset('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([
|
AuditEvent::create([
|
||||||
'user_id' => Auth::id(),
|
'user_id' => Auth::id(),
|
||||||
'actor' => Auth::user()->name ?? 'system',
|
'actor' => Auth::user()->name ?? 'system',
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
|
@if ($openRecoveryModal)
|
||||||
|
<div x-data x-init="$dispatch('openModal', { component: 'modals.recovery-codes' })"></div>
|
||||||
|
@endif
|
||||||
{{-- Identity header --}}
|
{{-- Identity header --}}
|
||||||
<div class="flex flex-wrap items-center gap-4 rounded-xl border border-line bg-surface p-5 shadow-panel">
|
<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>
|
<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