Reveal backup codes only when freshly generated

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) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-15 06:14:59 +02:00
parent bdccd05cd9
commit dd891cd11e
8 changed files with 84 additions and 22 deletions

View File

@ -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);

View File

@ -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<int, string> */
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() : [],
]);
}
}

View File

@ -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');
}

View File

@ -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',

View File

@ -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',

View File

@ -9,6 +9,7 @@
</div>
</div>
@if ($revealed)
<div class="mt-4 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>
@ -21,14 +22,22 @@
</div>
<div class="mt-6 flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<x-btn href="{{ route('two-factor.recovery.download') }}" variant="secondary">
<x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('auth.recovery_download') }}
</x-btn>
<x-btn variant="primary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
</div>
@else
<div class="mt-4 flex items-start gap-2.5 rounded-md border border-line bg-inset px-4 py-3">
<x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-ink-3" />
<p class="text-sm text-ink-2">{{ __('auth.recovery_hidden_notice') }}</p>
</div>
<div class="mt-6 flex flex-wrap items-center justify-between gap-2">
<x-btn variant="secondary" wire:click="regenerate" wire:target="regenerate" wire:loading.attr="disabled">
<x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('auth.recovery_regenerate') }}
</x-btn>
</div>
<x-btn variant="primary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
</div>
@endif
</div>

View File

@ -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

View File

@ -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