CluPilotCloud/app/Livewire/Admin/TwoFactorSetup.php

200 lines
7.3 KiB
PHP

<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Livewire\Concerns\ResolvesOperator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Where an operator sets their own two-factor login up.
*
* Its own page, not a card on Admin\Settings. RequireOperatorTwoFactor must
* be able to exempt ENROLMENT alone while compulsory two-factor is on — the
* earlier shape exempted the whole of Admin\Settings on the theory that it
* was "the page where two-factor is set up", but that component also carries
* staff management, site visibility, network restrictions, account changes,
* and the two-factor policy switch itself. An unenrolled operator got
* unrestricted access to all of that, not just enrolment (Codex R15, P1b).
* One route to exempt is one decision; eight actions on a shared page each
* remembering to check is eight chances to forget one.
*
* Reachable by EVERY operator, not gated behind any capability — the
* compulsory switch (Admin\Settings::saveTwoFactorPolicy()) applies to every
* member of staff, so enrolment has to be reachable by every operator too.
*
* Every action is re-checked, server-side, against a recent password
* confirmation on every single call — not merely by hiding the buttons: a
* Livewire action is reachable by anyone who can post to /livewire/update,
* and "the form was not on screen" has never stopped anybody.
*/
#[Layout('layouts.admin')]
class TwoFactorSetup extends Component
{
use ConfirmsPassword;
use ResolvesOperator;
/** Two-factor is confirmed against the operator's own record, not a portal one. */
protected function confirmationGuard(): string
{
return 'operator';
}
/** Shown once, right after setting two-factor up. */
public ?array $recoveryCodes = null;
public string $twoFactorCode = '';
/**
* Start two-factor setup: generate the secret, then show the QR code.
*
* Not confirmed yet — Fortify keeps `two_factor_confirmed_at` null until a
* code from the app has been accepted, so someone who scans nothing and
* closes the tab is not left locked out of their own account.
*/
public function enableTwoFactor(): void
{
$this->requireConfirmedPassword();
if (! $operator = $this->currentOperator()) {
return;
}
app(EnableTwoFactorAuthentication::class)($operator);
}
/**
* Step back out of a half-finished setup.
*
* Without this the only way out of the QR step was to navigate away, which
* left an unconfirmed secret sitting on the account — invisible, because
* nothing shows a pending enrolment anywhere else. Clearing it costs
* nothing: it was never confirmed, so it never protected anything.
*/
public function cancelSetup(): void
{
$this->requireConfirmedPassword();
if (! $operator = $this->currentOperator()) {
return;
}
app(DisableTwoFactorAuthentication::class)($operator);
$this->twoFactorCode = '';
$this->resetErrorBag('twoFactorCode');
$this->dispatch('notify', message: __('two_factor_setup.cancelled'));
}
/** Accept a code from the app, which is what actually turns it on. */
public function confirmTwoFactor(): void
{
$this->requireConfirmedPassword();
if (! $operator = $this->currentOperator()) {
return;
}
try {
app(ConfirmTwoFactorAuthentication::class)(
$operator,
$this->twoFactorCode,
);
} catch (ValidationException $e) {
$this->addError('twoFactorCode', $e->errors()['code'][0] ?? __('two_factor_setup.code_wrong'));
return;
}
$this->twoFactorCode = '';
// Shown once, here, because this is the only moment they exist in a
// form anyone will read. They stay retrievable afterwards, but nobody
// writes down what they were not shown.
$this->recoveryCodes = json_decode(decrypt($operator->two_factor_recovery_codes), true);
$this->dispatch('notify', message: __('two_factor_setup.on'));
}
public function regenerateRecoveryCodes(): void
{
$this->requireConfirmedPassword();
if (! $operator = $this->currentOperator()) {
return;
}
app(GenerateNewRecoveryCodes::class)($operator);
$this->recoveryCodes = json_decode(decrypt($operator->refresh()->two_factor_recovery_codes), true);
}
public function disableTwoFactor(): void
{
$this->requireConfirmedPassword();
if (! $operator = $this->currentOperator()) {
return;
}
app(DisableTwoFactorAuthentication::class)($operator);
$this->recoveryCodes = null;
$this->dispatch('notify', message: __('two_factor_setup.off'));
}
/**
* The disable button opens ConfirmDisableTwoFactor instead of calling
* disableTwoFactor() directly (R23); its confirm button dispatches back
* here, and disableTwoFactor() still runs its own password check.
*/
#[On('two-factor-disable-confirmed')]
public function onDisableConfirmed(): void
{
$this->disableTwoFactor();
}
/**
* Every two-factor action goes through this.
*
* Server-side, on each call, and not merely by hiding the buttons: a
* Livewire action is reachable by anyone who can post to /livewire/update,
* and "the form was not on screen" has never stopped anybody.
*/
private function requireConfirmedPassword(): void
{
abort_unless($this->passwordRecentlyConfirmed(), 403);
}
public function render()
{
// Unlike the actions above, render() has no requireConfirmedPassword()
// gate in front of it — Livewire calls it on every update, including a
// bare property sync, so it is its own reachable path for a session
// that ended after the page loaded.
if (! $operator = $this->currentOperator()) {
return view('livewire.admin.two-factor-setup', [
'twoFactorPending' => false,
'twoFactorOn' => false,
'twoFactorQr' => null,
'passwordConfirmed' => false,
]);
}
return view('livewire.admin.two-factor-setup', [
// Never the secret itself — only whether it exists, and the SVG
// Fortify renders from it. The secret in a Livewire property would
// travel to the browser and back in the component snapshot.
'twoFactorPending' => $operator->two_factor_secret !== null && $operator->two_factor_confirmed_at === null,
'twoFactorOn' => $operator->two_factor_confirmed_at !== null,
'twoFactorQr' => $operator->two_factor_secret !== null && $operator->two_factor_confirmed_at === null
? $operator->twoFactorQrCodeSvg()
: null,
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
]);
}
}