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
parent
b2cb94bb32
commit
764e1e5e53
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -39,7 +39,10 @@ class TwoFactorSetup extends Component
|
|||
'two_factor_confirmed_at' => now(),
|
||||
])->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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -106,7 +106,10 @@
|
|||
</div>
|
||||
</div>
|
||||
@if ($twoFactorEnabled)
|
||||
<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
|
||||
<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') }}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,19 @@ Route::middleware('auth')->group(function () {
|
|||
Route::get('/password', Auth\PasswordChange::class)->name('password.change');
|
||||
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 () {
|
||||
AuthFacade::logout();
|
||||
session()->invalidate();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue