140 lines
5.4 KiB
PHP
140 lines
5.4 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Admin;
|
|
|
|
use App\Livewire\Concerns\ConfirmsPassword;
|
|
use Illuminate\Support\Facades\Auth;
|
|
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\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;
|
|
|
|
/** 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();
|
|
|
|
app(EnableTwoFactorAuthentication::class)(Auth::guard('operator')->user());
|
|
}
|
|
|
|
/** Accept a code from the app, which is what actually turns it on. */
|
|
public function confirmTwoFactor(): void
|
|
{
|
|
$this->requireConfirmedPassword();
|
|
|
|
$operator = Auth::guard('operator')->user();
|
|
|
|
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();
|
|
|
|
$operator = Auth::guard('operator')->user();
|
|
app(GenerateNewRecoveryCodes::class)($operator);
|
|
$this->recoveryCodes = json_decode(decrypt($operator->refresh()->two_factor_recovery_codes), true);
|
|
}
|
|
|
|
public function disableTwoFactor(): void
|
|
{
|
|
$this->requireConfirmedPassword();
|
|
|
|
app(DisableTwoFactorAuthentication::class)(Auth::guard('operator')->user());
|
|
$this->recoveryCodes = null;
|
|
$this->dispatch('notify', message: __('two_factor_setup.off'));
|
|
}
|
|
|
|
/**
|
|
* 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()
|
|
{
|
|
$operator = Auth::guard('operator')->user();
|
|
|
|
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(),
|
|
]);
|
|
}
|
|
}
|