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
parent
bdccd05cd9
commit
dd891cd11e
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() : [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue