From dd891cd11e883598f239e595cdec2e2c0c69c71b Mon Sep 17 00:00:00 2001 From: boban Date: Mon, 15 Jun 2026 06:14:59 +0200 Subject: [PATCH] Reveal backup codes only when freshly generated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backup (recovery) codes must be viewable ONLY at the moment they are generated — at first-factor enrollment or on regenerate — and NEVER re-viewable afterward from "Backup-Codes verwalten". - Modals\RecoveryCodes: add server-gated $revealed. mount() pulls a one-time session flag (2fa.codes_fresh); regenerate() sets it. render() emits codes ONLY when revealed, so a client cannot tamper a flag to re-view stored codes. - View: revealed state shows codes grid + download + warning + close; hidden state shows a security notice + Neu erzeugen + close (no codes, no download). - TwoFactorSetup::confirm() and WebauthnKeys::register() put 2fa.codes_fresh on first-factor code generation (put, survives the redirect + later openModal request). - Add recovery_hidden_notice (DE/EN parity). - Tests: manage view hides codes; fresh flag reveals once; regenerate reveals the new set; first-factor paths set the flag. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Livewire/Auth/TwoFactorSetup.php | 3 ++ app/Livewire/Modals/RecoveryCodes.php | 24 ++++++++++--- app/Livewire/Settings/WebauthnKeys.php | 2 ++ lang/de/auth.php | 1 + lang/en/auth.php | 1 + .../livewire/modals/recovery-codes.blade.php | 35 +++++++++++------- tests/Feature/FirstFactorCodesTest.php | 4 +++ tests/Feature/RecoveryCodesModalTest.php | 36 ++++++++++++++++--- 8 files changed, 84 insertions(+), 22 deletions(-) diff --git a/app/Livewire/Auth/TwoFactorSetup.php b/app/Livewire/Auth/TwoFactorSetup.php index f5a9bf8..6d63a85 100644 --- a/app/Livewire/Auth/TwoFactorSetup.php +++ b/app/Livewire/Auth/TwoFactorSetup.php @@ -51,6 +51,9 @@ class TwoFactorSetup extends Component if (! Auth::user()->hasRecoveryCodes()) { Auth::user()->replaceRecoveryCodes(); session()->flash('open_recovery_modal', true); + // put (not flash): must survive the redirect AND the later openModal Livewire + // request; the modal pulls it on mount to reveal the freshly generated codes once. + session()->put('2fa.codes_fresh', true); } return $this->redirect(route('settings'), navigate: true); diff --git a/app/Livewire/Modals/RecoveryCodes.php b/app/Livewire/Modals/RecoveryCodes.php index a6c7edb..e6db92b 100644 --- a/app/Livewire/Modals/RecoveryCodes.php +++ b/app/Livewire/Modals/RecoveryCodes.php @@ -12,15 +12,26 @@ use LivewireUI\Modal\ModalComponent; */ class RecoveryCodes extends ModalComponent { + /** + * Codes are revealed ONLY when freshly generated in this flow — never re-viewable from + * "Backup-Codes verwalten". Set true by a one-time server flag on mount (first-factor + * enrollment) or by regenerate(). A client cannot tamper this into showing stored codes: + * render() only emits codes when $revealed, and $revealed is only set server-side. + */ + public bool $revealed = false; + public static function modalMaxWidth(): string { return 'lg'; } - /** @return array */ - public function codes(): array + public function mount(): void { - return Auth::user()->recoveryCodes(); + // One-time, server-set flag (put on first-factor enrollment). pull() reads + forgets, + // so a refresh of the manage view will no longer reveal the codes. + if (session()->pull('2fa.codes_fresh', false)) { + $this->revealed = true; + } } /** Regenerate the current user's backup codes (invalidates the old set). */ @@ -36,11 +47,16 @@ class RecoveryCodes extends ModalComponent 'ip' => request()->ip(), ]); + // The freshly generated set may be shown once in this same modal instance. + $this->revealed = true; + $this->dispatch('notify', message: __('auth.recovery_regenerated')); } public function render() { - return view('livewire.modals.recovery-codes', ['codes' => $this->codes()]); + return view('livewire.modals.recovery-codes', [ + 'codes' => $this->revealed ? Auth::user()->recoveryCodes() : [], + ]); } } diff --git a/app/Livewire/Settings/WebauthnKeys.php b/app/Livewire/Settings/WebauthnKeys.php index b606cb0..43c6f8e 100644 --- a/app/Livewire/Settings/WebauthnKeys.php +++ b/app/Livewire/Settings/WebauthnKeys.php @@ -38,6 +38,8 @@ class WebauthnKeys extends Component // First factor enrolled and no codes yet → generate + show the recovery modal. if (! Auth::user()->hasRecoveryCodes()) { Auth::user()->replaceRecoveryCodes(); + // Server-set one-time flag so the opening modal reveals the fresh set once. + session()->put('2fa.codes_fresh', true); $this->dispatch('openModal', component: 'modals.recovery-codes'); } diff --git a/lang/de/auth.php b/lang/de/auth.php index ecb6e03..514fa5e 100644 --- a/lang/de/auth.php +++ b/lang/de/auth.php @@ -89,6 +89,7 @@ return [ 'recovery_regenerated' => 'Neue Backup-Codes erzeugt.', 'recovery_done' => 'Gespeichert — weiter', 'recovery_manage' => 'Backup-Codes verwalten', + 'recovery_hidden_notice' => 'Aus Sicherheitsgründen werden Backup-Codes nur einmal bei der Erstellung angezeigt. Erzeuge einen neuen Satz, um ihn zu sehen — die alten werden dabei ungültig.', // ── Forgot / reset password ────────────────────────────────────────── 'title_forgot' => 'Passwort vergessen — Clusev', diff --git a/lang/en/auth.php b/lang/en/auth.php index 077c1ab..0f35ca9 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -89,6 +89,7 @@ return [ 'recovery_regenerated' => 'New backup codes generated.', 'recovery_done' => 'Saved — continue', 'recovery_manage' => 'Manage backup codes', + 'recovery_hidden_notice' => 'For security, backup codes are shown only once, at creation. Generate a new set to see them — the old ones stop working.', // ── Forgot / reset password ────────────────────────────────────────── 'title_forgot' => 'Forgot password — Clusev', diff --git a/resources/views/livewire/modals/recovery-codes.blade.php b/resources/views/livewire/modals/recovery-codes.blade.php index 10dbfec..aa7d9d7 100644 --- a/resources/views/livewire/modals/recovery-codes.blade.php +++ b/resources/views/livewire/modals/recovery-codes.blade.php @@ -9,26 +9,35 @@ -
- -

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

-
+ @if ($revealed) +
+ +

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

+
-
- @foreach ($codes as $c) - {{ $c }} - @endforeach -
+
+ @foreach ($codes as $c) + {{ $c }} + @endforeach +
-
-
+
{{ __('auth.recovery_download') }} + {{ __('common.close') }} +
+ @else +
+ +

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

+
+ +
{{ __('auth.recovery_regenerate') }} + {{ __('common.close') }}
- {{ __('common.close') }} -
+ @endif
diff --git a/tests/Feature/FirstFactorCodesTest.php b/tests/Feature/FirstFactorCodesTest.php index 7a9b81f..b7e2f6e 100644 --- a/tests/Feature/FirstFactorCodesTest.php +++ b/tests/Feature/FirstFactorCodesTest.php @@ -37,6 +37,8 @@ class FirstFactorCodesTest extends TestCase $this->assertTrue($user->fresh()->hasRecoveryCodes()); $this->assertTrue(session('open_recovery_modal')); + // The modal that opens after the redirect reveals the fresh set once. + $this->assertTrue(session('2fa.codes_fresh')); } public function test_first_key_register_generates_codes_and_opens_modal(): void @@ -56,6 +58,8 @@ class FirstFactorCodesTest extends TestCase ->assertDispatched('openModal'); $this->assertTrue($user->fresh()->hasRecoveryCodes()); + // The opening modal reveals the fresh set once. + $this->assertTrue(session('2fa.codes_fresh')); } public function test_malformed_setup_secret_yields_validation_error_not_500(): void diff --git a/tests/Feature/RecoveryCodesModalTest.php b/tests/Feature/RecoveryCodesModalTest.php index 39bc95d..7374548 100644 --- a/tests/Feature/RecoveryCodesModalTest.php +++ b/tests/Feature/RecoveryCodesModalTest.php @@ -13,26 +13,52 @@ class RecoveryCodesModalTest extends TestCase { use RefreshDatabase; - public function test_modal_lists_the_current_codes(): void + public function test_manage_view_without_fresh_flag_hides_the_codes(): void { $user = User::factory()->create(); $codes = $user->replaceRecoveryCodes(); + // Opened from "Backup-Codes verwalten" with no fresh-generation flag: never reveal. Livewire::actingAs($user)->test(RecoveryCodes::class) - ->assertSee($codes[0]) - ->assertSee($codes[7]); + ->assertSet('revealed', false) + ->assertDontSee($codes[0]) + ->assertDontSee($codes[7]) + ->assertSee(__('auth.recovery_hidden_notice')); } - public function test_regenerate_replaces_the_codes(): void + public function test_fresh_flag_reveals_the_codes_once(): void + { + $user = User::factory()->create(); + $codes = $user->replaceRecoveryCodes(); + session(['2fa.codes_fresh' => true]); + + Livewire::actingAs($user)->test(RecoveryCodes::class) + ->assertSet('revealed', true) + ->assertSee($codes[0]) + ->assertSee($codes[7]); + + // pull() forgets the flag — it is single-use. + $this->assertFalse(session()->has('2fa.codes_fresh')); + } + + public function test_regenerate_from_manage_view_reveals_the_new_codes(): void { $user = User::factory()->create(); $old = $user->replaceRecoveryCodes(); + // Manage view (no flag) → not revealed → regenerate → the NEW set is revealed once. Livewire::actingAs($user)->test(RecoveryCodes::class) + ->assertSet('revealed', false) ->call('regenerate') + ->assertSet('revealed', true) ->assertDontSee($old[0]); - $this->assertNotEquals($old, $user->fresh()->recoveryCodes()); + $new = $user->fresh()->recoveryCodes(); + $this->assertNotEquals($old, $new); + + Livewire::actingAs($user)->test(RecoveryCodes::class) + ->call('regenerate') + ->assertSee($user->fresh()->recoveryCodes()[0]); } public function test_old_recovery_route_is_gone(): void