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()) {
|
if (! Auth::user()->hasRecoveryCodes()) {
|
||||||
Auth::user()->replaceRecoveryCodes();
|
Auth::user()->replaceRecoveryCodes();
|
||||||
session()->flash('open_recovery_modal', true);
|
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);
|
return $this->redirect(route('settings'), navigate: true);
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,26 @@ use LivewireUI\Modal\ModalComponent;
|
||||||
*/
|
*/
|
||||||
class RecoveryCodes extends 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
|
public static function modalMaxWidth(): string
|
||||||
{
|
{
|
||||||
return 'lg';
|
return 'lg';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<int, string> */
|
public function mount(): void
|
||||||
public function codes(): array
|
|
||||||
{
|
{
|
||||||
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). */
|
/** Regenerate the current user's backup codes (invalidates the old set). */
|
||||||
|
|
@ -36,11 +47,16 @@ class RecoveryCodes extends ModalComponent
|
||||||
'ip' => request()->ip(),
|
'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'));
|
$this->dispatch('notify', message: __('auth.recovery_regenerated'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
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.
|
// First factor enrolled and no codes yet → generate + show the recovery modal.
|
||||||
if (! Auth::user()->hasRecoveryCodes()) {
|
if (! Auth::user()->hasRecoveryCodes()) {
|
||||||
Auth::user()->replaceRecoveryCodes();
|
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');
|
$this->dispatch('openModal', component: 'modals.recovery-codes');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@ return [
|
||||||
'recovery_regenerated' => 'Neue Backup-Codes erzeugt.',
|
'recovery_regenerated' => 'Neue Backup-Codes erzeugt.',
|
||||||
'recovery_done' => 'Gespeichert — weiter',
|
'recovery_done' => 'Gespeichert — weiter',
|
||||||
'recovery_manage' => 'Backup-Codes verwalten',
|
'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 ──────────────────────────────────────────
|
// ── Forgot / reset password ──────────────────────────────────────────
|
||||||
'title_forgot' => 'Passwort vergessen — Clusev',
|
'title_forgot' => 'Passwort vergessen — Clusev',
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@ return [
|
||||||
'recovery_regenerated' => 'New backup codes generated.',
|
'recovery_regenerated' => 'New backup codes generated.',
|
||||||
'recovery_done' => 'Saved — continue',
|
'recovery_done' => 'Saved — continue',
|
||||||
'recovery_manage' => 'Manage backup codes',
|
'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 ──────────────────────────────────────────
|
// ── Forgot / reset password ──────────────────────────────────────────
|
||||||
'title_forgot' => 'Forgot password — Clusev',
|
'title_forgot' => 'Forgot password — Clusev',
|
||||||
|
|
|
||||||
|
|
@ -9,26 +9,35 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex items-start gap-2.5 rounded-md border border-warning/25 bg-warning/10 px-4 py-3">
|
@if ($revealed)
|
||||||
<x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
<div class="mt-4 flex items-start gap-2.5 rounded-md border border-warning/25 bg-warning/10 px-4 py-3">
|
||||||
<p class="text-sm text-ink-2">{{ __('auth.recovery_warning') }}</p>
|
<x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
||||||
</div>
|
<p class="text-sm text-ink-2">{{ __('auth.recovery_warning') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 grid grid-cols-1 gap-2 rounded-md border border-line bg-inset p-4 sm:grid-cols-2">
|
<div class="mt-4 grid grid-cols-1 gap-2 rounded-md border border-line bg-inset p-4 sm:grid-cols-2">
|
||||||
@foreach ($codes as $c)
|
@foreach ($codes as $c)
|
||||||
<span class="font-mono text-sm tracking-wide text-ink">{{ $c }}</span>
|
<span class="font-mono text-sm tracking-wide text-ink">{{ $c }}</span>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex flex-wrap items-center justify-between gap-2">
|
<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-btn href="{{ route('two-factor.recovery.download') }}" variant="secondary">
|
||||||
<x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('auth.recovery_download') }}
|
<x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('auth.recovery_download') }}
|
||||||
</x-btn>
|
</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-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-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('auth.recovery_regenerate') }}
|
||||||
</x-btn>
|
</x-btn>
|
||||||
|
<x-btn variant="primary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
|
||||||
</div>
|
</div>
|
||||||
<x-btn variant="primary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
|
@endif
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ class FirstFactorCodesTest extends TestCase
|
||||||
|
|
||||||
$this->assertTrue($user->fresh()->hasRecoveryCodes());
|
$this->assertTrue($user->fresh()->hasRecoveryCodes());
|
||||||
$this->assertTrue(session('open_recovery_modal'));
|
$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
|
public function test_first_key_register_generates_codes_and_opens_modal(): void
|
||||||
|
|
@ -56,6 +58,8 @@ class FirstFactorCodesTest extends TestCase
|
||||||
->assertDispatched('openModal');
|
->assertDispatched('openModal');
|
||||||
|
|
||||||
$this->assertTrue($user->fresh()->hasRecoveryCodes());
|
$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
|
public function test_malformed_setup_secret_yields_validation_error_not_500(): void
|
||||||
|
|
|
||||||
|
|
@ -13,26 +13,52 @@ class RecoveryCodesModalTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
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();
|
$user = User::factory()->create();
|
||||||
$codes = $user->replaceRecoveryCodes();
|
$codes = $user->replaceRecoveryCodes();
|
||||||
|
|
||||||
|
// Opened from "Backup-Codes verwalten" with no fresh-generation flag: never reveal.
|
||||||
Livewire::actingAs($user)->test(RecoveryCodes::class)
|
Livewire::actingAs($user)->test(RecoveryCodes::class)
|
||||||
->assertSee($codes[0])
|
->assertSet('revealed', false)
|
||||||
->assertSee($codes[7]);
|
->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();
|
$user = User::factory()->create();
|
||||||
$old = $user->replaceRecoveryCodes();
|
$old = $user->replaceRecoveryCodes();
|
||||||
|
|
||||||
|
// Manage view (no flag) → not revealed → regenerate → the NEW set is revealed once.
|
||||||
Livewire::actingAs($user)->test(RecoveryCodes::class)
|
Livewire::actingAs($user)->test(RecoveryCodes::class)
|
||||||
|
->assertSet('revealed', false)
|
||||||
->call('regenerate')
|
->call('regenerate')
|
||||||
|
->assertSet('revealed', true)
|
||||||
->assertDontSee($old[0]);
|
->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
|
public function test_old_recovery_route_is_gone(): void
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue