feat(auth): show + download + regenerate 2FA backup codes

2FA confirm now generates 8 backup codes and redirects to a show-once recovery
view (download as .txt, regenerate). Reachable again from Settings. Codes are
shown before the onboarding gate so a fresh enrollee can save them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 16:52:23 +02:00
parent b2cb94bb32
commit 764e1e5e53
6 changed files with 139 additions and 2 deletions

View File

@ -0,0 +1,35 @@
<?php
namespace App\Livewire\Auth;
use App\Models\AuditEvent;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.auth')]
class RecoveryCodes extends Component
{
/** 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.auth.recovery-codes', [
'codes' => Auth::user()->recoveryCodes(),
])->title(__('auth.title_recovery'));
}
}

View File

@ -39,7 +39,10 @@ class TwoFactorSetup extends Component
'two_factor_confirmed_at' => now(), 'two_factor_confirmed_at' => now(),
])->save(); ])->save();
return $this->redirect(route('dashboard'), navigate: true); // Generate backup codes and show them once before entering the panel.
Auth::user()->replaceRecoveryCodes();
return $this->redirect(route('two-factor.recovery'), navigate: true);
} }
public function qrCode(): string public function qrCode(): string

View File

@ -0,0 +1,28 @@
<div class="space-y-5">
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('auth.two_factor') }}</p>
<h1 class="mt-1 font-display text-2xl font-semibold text-ink">{{ __('auth.recovery_heading') }}</h1>
<p class="mt-1.5 text-sm text-ink-2">{{ __('auth.recovery_subtitle') }}</p>
</div>
<div class="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="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="flex flex-wrap items-center gap-2">
<x-btn href="{{ route('two-factor.recovery.download') }}" variant="accent">
<x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('auth.recovery_download') }}
</x-btn>
<x-btn variant="secondary" wire:click="regenerate" wire:confirm="{{ __('auth.recovery_regenerate_confirm') }}">
<x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('auth.recovery_regenerate') }}
</x-btn>
<x-btn href="{{ route('dashboard') }}" variant="primary" class="ml-auto">{{ __('auth.recovery_done') }}</x-btn>
</div>
</div>

View File

@ -106,7 +106,10 @@
</div> </div>
</div> </div>
@if ($twoFactorEnabled) @if ($twoFactorEnabled)
<x-btn variant="danger-soft" wire:click="confirmDisableTwoFactor">{{ __('common.disable') }}</x-btn> <div class="flex items-center gap-2">
<x-btn variant="secondary" :href="route('two-factor.recovery')" wire:navigate>{{ __('auth.recovery_manage') }}</x-btn>
<x-btn variant="danger-soft" wire:click="confirmDisableTwoFactor">{{ __('common.disable') }}</x-btn>
</div>
@else @else
<x-btn variant="accent" :href="route('two-factor.setup')" wire:navigate> <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-icon name="shield" class="h-3.5 w-3.5" /> {{ __('settings.twofa_setup') }}

View File

@ -51,6 +51,19 @@ Route::middleware('auth')->group(function () {
Route::get('/password', Auth\PasswordChange::class)->name('password.change'); Route::get('/password', Auth\PasswordChange::class)->name('password.change');
Route::get('/two-factor-setup', Auth\TwoFactorSetup::class)->name('two-factor.setup'); Route::get('/two-factor-setup', Auth\TwoFactorSetup::class)->name('two-factor.setup');
// 2FA backup codes — shown right after enrolling (before the onboarding gate) and
// manageable later from Settings.
Route::get('/two-factor/recovery-codes', Auth\RecoveryCodes::class)->name('two-factor.recovery');
Route::get('/two-factor/recovery-codes/download', function () {
$codes = AuthFacade::user()->recoveryCodes();
return response()->streamDownload(
fn () => print (implode("\n", $codes)."\n"),
'clusev-recovery-codes.txt',
['Content-Type' => 'text/plain; charset=UTF-8'],
);
})->name('two-factor.recovery.download');
Route::post('/logout', function () { Route::post('/logout', function () {
AuthFacade::logout(); AuthFacade::logout();
session()->invalidate(); session()->invalidate();

View File

@ -0,0 +1,55 @@
<?php
namespace Tests\Feature;
use App\Livewire\Auth\RecoveryCodes;
use App\Livewire\Auth\TwoFactorSetup;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use PragmaRX\Google2FAQRCode\Google2FA;
use Tests\TestCase;
class RecoveryCodesViewTest extends TestCase
{
use RefreshDatabase;
public function test_confirming_2fa_generates_codes_and_redirects_to_recovery(): void
{
$user = User::factory()->create(['two_factor_secret' => null, 'two_factor_confirmed_at' => null]);
$this->actingAs($user);
$secret = (new Google2FA)->generateSecretKey();
$code = (new Google2FA)->getCurrentOtp($secret);
Livewire::test(TwoFactorSetup::class)
->set('secret', $secret)
->set('code', $code)
->call('confirm')
->assertRedirect(route('two-factor.recovery'));
$this->assertCount(8, $user->fresh()->recoveryCodes());
}
public function test_regenerate_replaces_codes(): void
{
$user = User::factory()->create(['two_factor_secret' => 'x', 'two_factor_confirmed_at' => now()]);
$old = $user->replaceRecoveryCodes();
$this->actingAs($user);
Livewire::test(RecoveryCodes::class)->call('regenerate');
$this->assertNotSame($old, $user->fresh()->recoveryCodes());
$this->assertCount(8, $user->fresh()->recoveryCodes());
}
public function test_download_returns_codes_as_text(): void
{
$user = User::factory()->create();
$user->replaceRecoveryCodes();
$this->actingAs($user);
$res = $this->get(route('two-factor.recovery.download'));
$res->assertOk();
$this->assertStringContainsString($user->recoveryCodes()[0], $res->streamedContent());
}
}