clusev/app/Livewire/Modals/RecoveryCodes.php

75 lines
2.7 KiB
PHP

<?php
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
/**
* Backup (recovery) codes, shown once in a modal. Opened on first-factor enrollment and
* from Settings → Security ("Backup-Codes verwalten"). The download endpoint stays a route.
*
* Show-once is structural, not UX-level: the component holds NO codes and NO "revealed"
* state. Codes reach the browser only via a transient `reveal-codes` event fired from
* mount() (one-time `2fa.codes_fresh` flag) or regenerate(). Because mount() never runs on
* hydrate and dispatched events live in the one-time response effects (never the snapshot),
* a replayed/stale snapshot has nothing to re-render and fires no event — captured codes
* cannot be re-revealed by replaying a signed snapshot.
*/
class RecoveryCodes extends ModalComponent
{
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(): void
{
// 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. mount() does not
// run on hydrate, so a replayed snapshot never re-triggers this.
if (session()->pull('2fa.codes_fresh', false)) {
$this->dispatchCodes();
}
}
/** Regenerate the current user's backup codes (invalidates the old set). */
public function regenerate(): void
{
Auth::user()->replaceRecoveryCodes();
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name ?? 'system',
'action' => 'two_factor.recovery_regenerate',
'target' => Auth::user()->email,
'ip' => request()->ip(),
]);
// One-time download grant (a timestamp) for the freshly regenerated set, consumed by
// the download route within a short window. Without it a later direct hit 403s.
session()->put('2fa.download_grant', now()->timestamp);
// Reveal the freshly generated set once, via the transient event (never a property).
$this->dispatchCodes();
$this->dispatch('notify', message: __('auth.recovery_regenerated'));
}
public function render()
{
return view('livewire.modals.recovery-codes');
}
/**
* Deliver the current user's recovery codes to the browser via a one-time event. The
* codes never enter a Livewire property or the snapshot — Alpine holds them in JS memory
* only, so a replayed snapshot cannot re-render them.
*/
protected function dispatchCodes(): void
{
$this->dispatch('reveal-codes', codes: Auth::user()->recoveryCodes());
}
}