Give operators somewhere to actually set up two-factor

console.require_2fa exempted admin.settings from the redirect it forces on
an unconfirmed operator, but that page only ever configured the global
policy — nobody could enrol behind it. A newly invited operator stayed
permanently redirected to a page with no way to satisfy the requirement,
the opposite of what the switch was for.

Adds enable/confirm/regenerate/disable to Admin\Settings, reusing Fortify's
own actions exactly as the portal's Settings already does — they only ever
touch the model they're handed, no guard or auth() call inside them, so
pointing ConfirmsPassword's guard at 'operator' is the only change needed.
The new card is deliberately not gated behind the site.manage capability
that hides the rest of this page's Owner-only sections: the compulsory
switch applies to every operator, so enrolment has to be reachable by every
operator too. Dropped the account card's stale hint pointing at a "separate
security area" that never existed.
feat/operator-identity
nexxo 2026-07-28 14:21:02 +02:00
parent 4ae5281c91
commit 913d19d39c
6 changed files with 434 additions and 14 deletions

View File

@ -2,20 +2,29 @@
namespace App\Livewire\Admin; namespace App\Livewire\Admin;
use App\Http\Middleware\RestrictConsoleNetwork;
use App\Livewire\Concerns\ChangesOwnPassword;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Models\Customer; use App\Models\Customer;
use App\Models\Operator; use App\Models\Operator;
use App\Models\VpnPeer; use App\Models\VpnPeer;
use App\Provisioning\Jobs\ApplyVpnPeer;
use App\Services\Deployment\UpdateChannel; use App\Services\Deployment\UpdateChannel;
use App\Support\Settings as AppSettings; use App\Support\Settings as AppSettings;
use App\Provisioning\Jobs\ApplyVpnPeer;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Spatie\Permission\Models\Role; 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\Layout;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
use Spatie\Permission\Models\Role;
use Symfony\Component\HttpFoundation\IpUtils;
/** /**
* Operator settings: the signed-in operator's own account plus (Owner-only) * Operator settings: the signed-in operator's own account plus (Owner-only)
@ -25,7 +34,8 @@ use Livewire\Component;
#[Layout('layouts.admin')] #[Layout('layouts.admin')]
class Settings extends Component class Settings extends Component
{ {
use \App\Livewire\Concerns\ChangesOwnPassword; use ChangesOwnPassword;
use ConfirmsPassword;
/** This page changes the signed-in OPERATOR's password, never a portal one. */ /** This page changes the signed-in OPERATOR's password, never a portal one. */
protected function passwordAccountGuard(): string protected function passwordAccountGuard(): string
@ -33,6 +43,87 @@ class Settings extends Component
return 'operator'; return 'operator';
} }
/** 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] ?? __('admin_settings.twofa_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: __('admin_settings.twofa_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: __('admin_settings.twofa_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);
}
// My account // My account
#[Validate('required|string|max:255')] #[Validate('required|string|max:255')]
public string $name = ''; public string $name = '';
@ -262,7 +353,7 @@ class Settings extends Component
$value = trim($this->consoleIp); $value = trim($this->consoleIp);
if (! \App\Http\Middleware\RestrictConsoleNetwork::isNetwork($value)) { if (! RestrictConsoleNetwork::isNetwork($value)) {
$this->addError('consoleIp', __('admin_settings.console_ip_invalid')); $this->addError('consoleIp', __('admin_settings.console_ip_invalid'));
return; return;
@ -298,8 +389,8 @@ class Settings extends Component
$ip = (string) request()->ip(); $ip = (string) request()->ip();
if (\App\Http\Middleware\RestrictConsoleNetwork::isRestricted() if (RestrictConsoleNetwork::isRestricted()
&& ! \App\Http\Middleware\RestrictConsoleNetwork::wouldStillAllow($ip, $list)) { && ! RestrictConsoleNetwork::wouldStillAllow($ip, $list)) {
$this->dispatch('notify', message: __('admin_settings.console_ip_last', ['ip' => $ip])); $this->dispatch('notify', message: __('admin_settings.console_ip_last', ['ip' => $ip]));
return; return;
@ -320,9 +411,9 @@ class Settings extends Component
{ {
$this->authorize('site.manage'); $this->authorize('site.manage');
$on = ! \App\Http\Middleware\RestrictConsoleNetwork::isRestricted(); $on = ! RestrictConsoleNetwork::isRestricted();
if ($on && ! \App\Http\Middleware\RestrictConsoleNetwork::allows((string) request()->ip())) { if ($on && ! RestrictConsoleNetwork::allows((string) request()->ip())) {
$this->dispatch('notify', message: __('admin_settings.console_lock_refused', ['ip' => (string) request()->ip()])); $this->dispatch('notify', message: __('admin_settings.console_lock_refused', ['ip' => (string) request()->ip()]));
return; return;
@ -355,7 +446,6 @@ class Settings extends Component
$this->dispatch('notify', message: __('admin_settings.saved')); $this->dispatch('notify', message: __('admin_settings.saved'));
} }
/** /**
* Ask the host-side agent to update this installation. * Ask the host-side agent to update this installation.
* *
@ -377,6 +467,8 @@ class Settings extends Component
public function render() public function render()
{ {
$operator = Auth::guard('operator')->user();
$staff = Operator::query() $staff = Operator::query()
->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::OPERATOR_ROLES)) ->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::OPERATOR_ROLES))
->with('roles') ->with('roles')
@ -396,13 +488,13 @@ class Settings extends Component
'sitePublic' => AppSettings::bool('site.public', true), 'sitePublic' => AppSettings::bool('site.public', true),
'canManageSite' => Auth::guard('operator')->user()?->can('site.manage') ?? false, 'canManageSite' => Auth::guard('operator')->user()?->can('site.manage') ?? false,
// Shown so nobody has to guess why they still see the real site. // Shown so nobody has to guess why they still see the real site.
'viewerOnVpn' => \Symfony\Component\HttpFoundation\IpUtils::checkIp( 'viewerOnVpn' => IpUtils::checkIp(
(string) request()->ip(), (string) request()->ip(),
(array) config('admin_access.trusted_ranges', []), (array) config('admin_access.trusted_ranges', []),
), ),
// Who may reach the console, and from where the viewer is asking — // Who may reach the console, and from where the viewer is asking —
// shown so the consequence of a change is visible before it is made. // shown so the consequence of a change is visible before it is made.
'consoleRestricted' => \App\Http\Middleware\RestrictConsoleNetwork::isRestricted(), 'consoleRestricted' => RestrictConsoleNetwork::isRestricted(),
'consoleIps' => (array) AppSettings::get('console.allowed_ips', []), 'consoleIps' => (array) AppSettings::get('console.allowed_ips', []),
'consoleVpnRanges' => (array) config('admin_access.trusted_ranges', []), 'consoleVpnRanges' => (array) config('admin_access.trusted_ranges', []),
'viewerIp' => (string) request()->ip(), 'viewerIp' => (string) request()->ip(),
@ -410,6 +502,16 @@ class Settings extends Component
'roles' => Operator::OPERATOR_ROLES, 'roles' => Operator::OPERATOR_ROLES,
'canManageStaff' => Auth::guard('operator')->user()->can('staff.manage'), 'canManageStaff' => Auth::guard('operator')->user()->can('staff.manage'),
'isOwner' => Auth::guard('operator')->user()->hasRole('Owner'), 'isOwner' => Auth::guard('operator')->user()->hasRole('Owner'),
// My own two-factor. 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(),
]); ]);
} }
} }

View File

@ -20,7 +20,6 @@ return [
'account_title' => 'Mein Konto', 'account_title' => 'Mein Konto',
'name' => 'Name', 'name' => 'Name',
'email' => 'E-Mail', 'email' => 'E-Mail',
'twofa_hint' => 'Passwort & Zwei-Faktor-Authentifizierung folgen in einem separaten Sicherheitsbereich.',
'account_saved' => 'Konto aktualisiert.', 'account_saved' => 'Konto aktualisiert.',
'staff_title' => 'Team & Zugriff', 'staff_title' => 'Team & Zugriff',
@ -85,6 +84,29 @@ return [
'two_factor_required' => 'Zwei-Faktor ist für die Konsole verbindlich. Bitte richten Sie sie hier ein.', 'two_factor_required' => 'Zwei-Faktor ist für die Konsole verbindlich. Bitte richten Sie sie hier ein.',
'two_factor_self_first' => 'Richten Sie zuerst Ihre eigene Zwei-Faktor-Bestätigung ein — sonst sperren Sie sich mit diesem Schalter selbst aus.', 'two_factor_self_first' => 'Richten Sie zuerst Ihre eigene Zwei-Faktor-Bestätigung ein — sonst sperren Sie sich mit diesem Schalter selbst aus.',
// My own two-factor enrolment — every operator, not only the Owner. This
// is the page RequireOperatorTwoFactor exempts from its redirect, so it
// has to actually work: the switch above is only fair if everyone it
// applies to has somewhere to satisfy it.
'twofa_title' => 'Meine Zwei-Faktor-Anmeldung',
'twofa_sub' => 'Zusätzlich zum Passwort ein Code aus einer App. Schützt Ihr Konto auch dann, wenn das Passwort irgendwo abhandenkommt.',
'twofa_state_on' => 'Aktiv',
'twofa_state_off' => 'Nicht eingerichtet',
'twofa_confirm_first' => 'Bitte zuerst Ihr Passwort bestätigen — eine angemeldete Sitzung allein genügt hier nicht.',
'twofa_confirm_button' => 'Bestätigen',
'twofa_enable' => 'Einrichten',
'twofa_scan' => 'Scannen Sie den Code mit Ihrer Authenticator-App und geben Sie danach die angezeigte Ziffernfolge ein.',
'twofa_code' => 'Code aus der App',
'twofa_activate' => 'Aktivieren',
'twofa_code_wrong' => 'Dieser Code stimmt nicht.',
'twofa_on' => 'Zwei-Faktor-Anmeldung ist aktiv.',
'twofa_off' => 'Zwei-Faktor-Anmeldung wurde entfernt.',
'twofa_off_confirm' => 'Zwei-Faktor-Anmeldung wirklich entfernen? Ihr Konto ist danach nur noch durch das Passwort geschützt.',
'twofa_disable' => 'Entfernen',
'twofa_new_codes' => 'Neue Wiederherstellungscodes',
'twofa_codes_title' => 'Wiederherstellungscodes',
'twofa_codes_hint' => 'Jetzt notieren und sicher verwahren. Sie sind der Weg zurück, wenn das Gerät verloren geht — jeder Code gilt einmal.',
'update_title' => 'Version & Aktualisierung', 'update_title' => 'Version & Aktualisierung',
'update_version' => 'Läuft', 'update_version' => 'Läuft',
'update_source' => 'Quelle', 'update_source' => 'Quelle',

View File

@ -20,7 +20,6 @@ return [
'account_title' => 'My account', 'account_title' => 'My account',
'name' => 'Name', 'name' => 'Name',
'email' => 'Email', 'email' => 'Email',
'twofa_hint' => 'Password & two-factor authentication follow in a separate security area.',
'account_saved' => 'Account updated.', 'account_saved' => 'Account updated.',
'staff_title' => 'Team & access', 'staff_title' => 'Team & access',
@ -85,6 +84,29 @@ return [
'two_factor_required' => 'Two-factor is compulsory for the console. Please set it up here.', 'two_factor_required' => 'Two-factor is compulsory for the console. Please set it up here.',
'two_factor_self_first' => 'Set up your own two-factor confirmation first — otherwise this switch locks you out yourself.', 'two_factor_self_first' => 'Set up your own two-factor confirmation first — otherwise this switch locks you out yourself.',
// My own two-factor enrolment — every operator, not only the Owner. This
// is the page RequireOperatorTwoFactor exempts from its redirect, so it
// has to actually work: the switch above is only fair if everyone it
// applies to has somewhere to satisfy it.
'twofa_title' => 'My two-factor login',
'twofa_sub' => 'A code from an app, in addition to your password. Protects your account even if the password ends up somewhere it should not.',
'twofa_state_on' => 'Active',
'twofa_state_off' => 'Not set up',
'twofa_confirm_first' => 'Please confirm your password first — a signed-in session alone is not enough here.',
'twofa_confirm_button' => 'Confirm',
'twofa_enable' => 'Set up',
'twofa_scan' => 'Scan the code with your authenticator app, then enter the digits it shows.',
'twofa_code' => 'Code from the app',
'twofa_activate' => 'Activate',
'twofa_code_wrong' => 'That code is not correct.',
'twofa_on' => 'Two-factor login is active.',
'twofa_off' => 'Two-factor login was removed.',
'twofa_off_confirm' => 'Really remove two-factor login? Your account is then protected by the password alone.',
'twofa_disable' => 'Remove',
'twofa_new_codes' => 'New recovery codes',
'twofa_codes_title' => 'Recovery codes',
'twofa_codes_hint' => 'Write these down now and keep them safe. They are the way back in if the device is lost — each code works once.',
'update_title' => 'Version & update', 'update_title' => 'Version & update',
'update_version' => 'Running', 'update_version' => 'Running',
'update_source' => 'Source', 'update_source' => 'Source',

View File

@ -298,13 +298,97 @@
</div> </div>
</form> </form>
{{-- My own two-factor. Reachable by EVERY operator, not only the Owner
this is not gated behind $canManageSite, on purpose: the compulsory
switch below applies to every member of staff, so every operator
needs somewhere to satisfy it. This is also the page
RequireOperatorTwoFactor exempts from the redirect it otherwise
forces while the switch is on without a working enrolment control
here, that exemption would lead nowhere.
Every action is re-checked server-side against a recent password
confirmation hiding the buttons stops nobody who can post to
/livewire/update. --}}
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:105ms]">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<div class="flex flex-wrap items-center gap-2">
<h2 class="font-semibold text-ink">{{ __('admin_settings.twofa_title') }}</h2>
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $twoFactorOn ? 'border-success-border bg-success-bg text-success' : 'border-line-strong bg-surface-2 text-muted' }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ $twoFactorOn ? __('admin_settings.twofa_state_on') : __('admin_settings.twofa_state_off') }}
</span>
</div>
<p class="mt-1.5 max-w-xl text-sm text-muted">{{ __('admin_settings.twofa_sub') }}</p>
</div>
</div>
@if (! $passwordConfirmed)
{{-- The gate. A signed-in session is not enough to change how the
account is protected the risk is an unlocked machine. --}}
<form wire:submit="confirmPassword" class="rounded-lg border border-line bg-surface-2 p-4">
<p class="text-sm text-body">{{ __('admin_settings.twofa_confirm_first') }}</p>
<div class="mt-3 flex flex-wrap items-start gap-2">
<div class="min-w-56 flex-1">
<x-ui.input name="confirmablePassword" type="password" autocomplete="current-password"
:label="__('admin_settings.password_current')" wire:model="confirmablePassword" />
</div>
<x-ui.button type="submit" variant="secondary" class="mt-7" wire:loading.attr="disabled" wire:target="confirmPassword">
{{ __('admin_settings.twofa_confirm_button') }}
</x-ui.button>
</div>
</form>
@elseif ($twoFactorOn)
<div class="flex flex-wrap gap-2">
<x-ui.button variant="secondary" wire:click="regenerateRecoveryCodes" wire:loading.attr="disabled" wire:target="regenerateRecoveryCodes">
{{ __('admin_settings.twofa_new_codes') }}
</x-ui.button>
<x-ui.button variant="danger" wire:click="disableTwoFactor"
wire:confirm="{{ __('admin_settings.twofa_off_confirm') }}"
wire:loading.attr="disabled" wire:target="disableTwoFactor">
{{ __('admin_settings.twofa_disable') }}
</x-ui.button>
</div>
@elseif ($twoFactorPending)
<div class="grid gap-5 sm:grid-cols-[auto_1fr] sm:items-start">
<div class="rounded-lg border border-line bg-white p-3">{!! $twoFactorQr !!}</div>
<form wire:submit="confirmTwoFactor" class="space-y-3">
<p class="text-sm text-muted">{{ __('admin_settings.twofa_scan') }}</p>
<div class="max-w-48">
<x-ui.input name="twoFactorCode" inputmode="numeric" autocomplete="one-time-code"
:label="__('admin_settings.twofa_code')" wire:model="twoFactorCode" />
</div>
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="confirmTwoFactor">
{{ __('admin_settings.twofa_activate') }}
</x-ui.button>
</form>
</div>
@else
<x-ui.button variant="primary" wire:click="enableTwoFactor" wire:loading.attr="disabled" wire:target="enableTwoFactor">
{{ __('admin_settings.twofa_enable') }}
</x-ui.button>
@endif
@if ($recoveryCodes)
{{-- Shown once, on purpose: nobody writes down what they were never
shown, and these are the way back in when the phone is gone. --}}
<div class="rounded-lg border border-warning-border bg-warning-bg p-4">
<p class="text-sm font-medium text-warning">{{ __('admin_settings.twofa_codes_title') }}</p>
<p class="mt-1 text-xs text-warning">{{ __('admin_settings.twofa_codes_hint') }}</p>
<ul class="mt-3 grid grid-cols-2 gap-x-6 gap-y-1 font-mono text-sm text-ink sm:grid-cols-4">
@foreach ($recoveryCodes as $code)<li class="select-all">{{ $code }}</li>@endforeach
</ul>
</div>
@endif
</div>
<form wire:submit="saveAccount" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]"> <form wire:submit="saveAccount" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="font-semibold text-ink">{{ __('admin_settings.account_title') }}</h2> <h2 class="font-semibold text-ink">{{ __('admin_settings.account_title') }}</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<x-ui.input name="name" wire:model="name" :label="__('admin_settings.name')" /> <x-ui.input name="name" wire:model="name" :label="__('admin_settings.name')" />
<x-ui.input name="email" wire:model="email" :label="__('admin_settings.email')" type="email" /> <x-ui.input name="email" wire:model="email" :label="__('admin_settings.email')" type="email" />
</div> </div>
<p class="text-xs text-muted">{{ __('admin_settings.twofa_hint') }}</p>
<div class="flex justify-end"> <div class="flex justify-end">
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveAccount">{{ __('admin_settings.save') }}</x-ui.button> <x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveAccount">{{ __('admin_settings.save') }}</x-ui.button>
</div> </div>

View File

@ -0,0 +1,149 @@
<?php
use App\Livewire\Admin\Settings;
use Livewire\Livewire;
use PragmaRX\Google2FA\Google2FA;
/**
* Operators setting up their own two-factor, from the console.
*
* Mirrors tests/Feature/CustomerTwoFactorTest.php on purpose: Fortify's four
* actions only ever touch the model they are handed (no guard, no auth()
* call inside them confirmed by reading the vendor source), so the same
* shape App\Livewire\Settings already used for the portal works here
* unchanged, just pointed at the `operator` guard instead of the default
* one via ConfirmsPassword::confirmationGuard().
*
* What matters here is the gate: every action is re-checked against a
* recent password confirmation on the server, because a Livewire action is
* reachable by anyone who can post to /livewire/update, and "the button was
* not on screen" has never stopped anyone. Uses a non-Owner role throughout
* this has to work for every operator, not only the one who can flip the
* compulsory switch.
*/
it('refuses every two-factor action without a recent password confirmation', function () {
$op = operator('Support');
foreach (['enableTwoFactor', 'confirmTwoFactor', 'disableTwoFactor', 'regenerateRecoveryCodes'] as $action) {
Livewire::actingAs($op, 'operator')
->test(Settings::class)
->call($action)
->assertForbidden();
}
expect($op->refresh()->two_factor_secret)->toBeNull();
});
it('takes a password, then sets two-factor up and confirms it with a real code', function () {
$op = operator('Support');
$page = Livewire::actingAs($op, 'operator')
->test(Settings::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->assertHasNoErrors();
$page->call('enableTwoFactor');
expect($op->refresh()->two_factor_secret)->not->toBeNull()
// Not on until a code has been accepted: someone who scans nothing and
// closes the tab must not be locked out of their own account.
->and($op->two_factor_confirmed_at)->toBeNull();
$code = app(Google2FA::class)->getCurrentOtp(decrypt($op->two_factor_secret));
$page->set('twoFactorCode', $code)->call('confirmTwoFactor')->assertHasNoErrors();
expect($op->refresh()->two_factor_confirmed_at)->not->toBeNull()
->and($page->get('recoveryCodes'))->toBeArray()->not->toBeEmpty();
});
it('rejects a wrong code instead of switching it on', function () {
$op = operator('Support');
Livewire::actingAs($op, 'operator')
->test(Settings::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->call('enableTwoFactor')
->set('twoFactorCode', '000000')
->call('confirmTwoFactor')
->assertHasErrors('twoFactorCode');
expect($op->refresh()->two_factor_confirmed_at)->toBeNull();
});
it('does not accept a wrong password, and says so without hinting', function () {
$op = operator('Support');
Livewire::actingAs($op, 'operator')
->test(Settings::class)
->set('confirmablePassword', 'falsch')
->call('confirmPassword')
->assertHasErrors('confirmablePassword')
->call('enableTwoFactor')
->assertForbidden();
});
it('never puts the secret where the browser can see it', function () {
// A Livewire property travels to the browser and back in the component
// snapshot. The QR image is derived from the secret; the secret is not.
$op = operator('Support');
$page = Livewire::actingAs($op, 'operator')
->test(Settings::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->call('enableTwoFactor');
$secret = decrypt($op->refresh()->two_factor_secret);
expect(json_encode($page->snapshot))->not->toContain($secret);
});
it('actually renders the enrolment control on the page, not just in the component', function () {
// The original gap was exactly this: a component with the right methods
// but a blade that never called them. Assert on rendered HTML, not
// component state, so a forgotten wire:click regresses this test too.
$op = operator('Support');
// Nothing to enrol with yet — the password gate is what shows.
$page = Livewire::actingAs($op, 'operator')->test(Settings::class);
$page->assertSee(__('admin_settings.twofa_confirm_first'))
->assertSee(__('admin_settings.twofa_state_off'));
$page->set('confirmablePassword', 'password')->call('confirmPassword');
$page->assertSee(__('admin_settings.twofa_enable'));
$page->call('enableTwoFactor');
$page->assertSee(__('admin_settings.twofa_activate'))
->assertSee(__('admin_settings.twofa_code'));
$code = app(Google2FA::class)->getCurrentOtp(decrypt($op->refresh()->two_factor_secret));
$page->set('twoFactorCode', $code)->call('confirmTwoFactor');
$page->assertSee(__('admin_settings.twofa_state_on'))
->assertSee(__('admin_settings.twofa_codes_title'))
->assertSee(__('admin_settings.twofa_disable'));
});
it('lets an operator regenerate recovery codes and disable two-factor once confirmed', function () {
$op = operator('Support');
$page = Livewire::actingAs($op, 'operator')
->test(Settings::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->call('enableTwoFactor');
$code = app(Google2FA::class)->getCurrentOtp(decrypt($op->refresh()->two_factor_secret));
$page->set('twoFactorCode', $code)->call('confirmTwoFactor')->assertHasNoErrors();
$firstCodes = $op->refresh()->two_factor_recovery_codes;
$page->call('regenerateRecoveryCodes');
expect($op->refresh()->two_factor_recovery_codes)->not->toBe($firstCodes);
$page->call('disableTwoFactor')->assertHasNoErrors();
expect($op->refresh()->two_factor_confirmed_at)->toBeNull()
->and($op->two_factor_secret)->toBeNull();
});

View File

@ -4,6 +4,7 @@ use App\Livewire\Admin\Settings;
use App\Models\Operator; use App\Models\Operator;
use App\Support\Settings as AppSettings; use App\Support\Settings as AppSettings;
use Livewire\Livewire; use Livewire\Livewire;
use PragmaRX\Google2FA\Google2FA;
it('is voluntary by default', function () { it('is voluntary by default', function () {
expect(AppSettings::bool('console.require_2fa', false))->toBeFalse(); expect(AppSettings::bool('console.require_2fa', false))->toBeFalse();
@ -47,6 +48,46 @@ it('refuses to switch it on while your own account has no two-factor', function
expect(AppSettings::bool('console.require_2fa', false))->toBeFalse(); expect(AppSettings::bool('console.require_2fa', false))->toBeFalse();
}); });
it('lets an operator without two-factor genuinely enrol while the switch is on, then reach the console', function () {
// The trap this guards against: the exemption alone is not enough if
// there is nowhere to actually enrol behind it. Uses a non-Owner role —
// the requirement applies to every operator, not only the one who can
// flip the switch.
AppSettings::set('console.require_2fa', true);
$operator = Operator::factory()->role('Support')->create(['password' => 'password']);
// Bounced off an ordinary page…
$this->actingAs($operator, 'operator')
->get(route('admin.overview'))
->assertRedirect(route('admin.settings'));
// …but the settings page itself is genuinely reachable, not another
// bounce in disguise.
$this->actingAs($operator, 'operator')
->get(route('admin.settings'))
->assertOk();
// Enrol for real: password confirmation, then a real TOTP code.
$page = Livewire::actingAs($operator, 'operator')
->test(Settings::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->assertHasNoErrors()
->call('enableTwoFactor');
$code = app(Google2FA::class)->getCurrentOtp(decrypt($operator->refresh()->two_factor_secret));
$page->set('twoFactorCode', $code)->call('confirmTwoFactor')->assertHasNoErrors();
expect($operator->refresh()->two_factor_confirmed_at)->not->toBeNull();
// And now the rest of the console actually opens.
$this->actingAs($operator->fresh(), 'operator')
->get(route('admin.overview'))
->assertOk();
});
it('allows it once your own two-factor is confirmed', function () { it('allows it once your own two-factor is confirmed', function () {
$operator = Operator::factory()->role('Owner')->create([ $operator = Operator::factory()->role('Owner')->create([
'two_factor_secret' => encrypt('JBSWY3DPEHPK3PXP'), 'two_factor_secret' => encrypt('JBSWY3DPEHPK3PXP'),