From 764e1e5e53b6fc7e945125b1d4801d7a9dd41acf Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 16:52:23 +0200 Subject: [PATCH] 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) --- app/Livewire/Auth/RecoveryCodes.php | 35 ++++++++++++ app/Livewire/Auth/TwoFactorSetup.php | 5 +- .../livewire/auth/recovery-codes.blade.php | 28 ++++++++++ .../views/livewire/settings/index.blade.php | 5 +- routes/web.php | 13 +++++ tests/Feature/RecoveryCodesViewTest.php | 55 +++++++++++++++++++ 6 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 app/Livewire/Auth/RecoveryCodes.php create mode 100644 resources/views/livewire/auth/recovery-codes.blade.php create mode 100644 tests/Feature/RecoveryCodesViewTest.php diff --git a/app/Livewire/Auth/RecoveryCodes.php b/app/Livewire/Auth/RecoveryCodes.php new file mode 100644 index 0000000..e0fefcc --- /dev/null +++ b/app/Livewire/Auth/RecoveryCodes.php @@ -0,0 +1,35 @@ +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')); + } +} diff --git a/app/Livewire/Auth/TwoFactorSetup.php b/app/Livewire/Auth/TwoFactorSetup.php index 273c1a2..8452e58 100644 --- a/app/Livewire/Auth/TwoFactorSetup.php +++ b/app/Livewire/Auth/TwoFactorSetup.php @@ -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 diff --git a/resources/views/livewire/auth/recovery-codes.blade.php b/resources/views/livewire/auth/recovery-codes.blade.php new file mode 100644 index 0000000..09dab08 --- /dev/null +++ b/resources/views/livewire/auth/recovery-codes.blade.php @@ -0,0 +1,28 @@ +
+
+

{{ __('auth.two_factor') }}

+

{{ __('auth.recovery_heading') }}

+

{{ __('auth.recovery_subtitle') }}

+
+ +
+ +

{{ __('auth.recovery_warning') }}

+
+ +
+ @foreach ($codes as $c) + {{ $c }} + @endforeach +
+ +
+ + {{ __('auth.recovery_download') }} + + + {{ __('auth.recovery_regenerate') }} + + {{ __('auth.recovery_done') }} +
+
diff --git a/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php index 08b86e4..ba0f613 100644 --- a/resources/views/livewire/settings/index.blade.php +++ b/resources/views/livewire/settings/index.blade.php @@ -106,7 +106,10 @@ @if ($twoFactorEnabled) - {{ __('common.disable') }} +
+ {{ __('auth.recovery_manage') }} + {{ __('common.disable') }} +
@else {{ __('settings.twofa_setup') }} diff --git a/routes/web.php b/routes/web.php index 1d458ef..d2dae66 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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(); diff --git a/tests/Feature/RecoveryCodesViewTest.php b/tests/Feature/RecoveryCodesViewTest.php new file mode 100644 index 0000000..93dcaae --- /dev/null +++ b/tests/Feature/RecoveryCodesViewTest.php @@ -0,0 +1,55 @@ +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()); + } +}