From 27292237c38ed739dbd8ec02113123b26e8ac643 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 28 Jul 2026 15:11:20 +0200 Subject: [PATCH] Move operator two-factor enrolment off admin.settings onto its own page RequireOperatorTwoFactor exempted the whole of admin.settings while compulsory two-factor was on, on the theory that it was "the page where two-factor is set up". That component also carries staff management, site visibility, network restrictions, account changes, and the two-factor policy switch itself, so an unenrolled operator got unrestricted access to all of that, not just enrolment. Enrolment (secret, QR, confirm, recovery codes, regenerate, disable) moves to its own route and component, admin.two-factor-setup / Admin\TwoFactorSetup, reachable by every operator regardless of capability. That is now the only page the middleware exempts besides logout, and its redirect points there instead. admin.settings goes back behind the policy like every other console page; only the policy switch itself stays there, since only someone who already satisfies it should be the one changing it. --- .../Middleware/RequireOperatorTwoFactor.php | 23 ++- app/Livewire/Admin/Settings.php | 105 +------------ app/Livewire/Admin/TwoFactorSetup.php | 139 ++++++++++++++++++ app/Support/Navigation.php | 5 + lang/de/admin.php | 1 + lang/de/admin_settings.php | 30 +--- lang/de/two_factor_setup.php | 22 +++ lang/en/admin.php | 1 + lang/en/admin_settings.php | 30 +--- lang/en/two_factor_setup.php | 22 +++ .../views/livewire/admin/settings.blade.php | 85 ----------- .../livewire/admin/two-factor-setup.blade.php | 75 ++++++++++ routes/admin.php | 6 +- .../Admin/OperatorTwoFactorEnrollmentTest.php | 39 +++-- .../Admin/OperatorTwoFactorPolicyTest.php | 40 ++++- 15 files changed, 359 insertions(+), 264 deletions(-) create mode 100644 app/Livewire/Admin/TwoFactorSetup.php create mode 100644 lang/de/two_factor_setup.php create mode 100644 lang/en/two_factor_setup.php create mode 100644 resources/views/livewire/admin/two-factor-setup.blade.php diff --git a/app/Http/Middleware/RequireOperatorTwoFactor.php b/app/Http/Middleware/RequireOperatorTwoFactor.php index 69b1c56..8ece44d 100644 --- a/app/Http/Middleware/RequireOperatorTwoFactor.php +++ b/app/Http/Middleware/RequireOperatorTwoFactor.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Support\AdminArea; use App\Support\Settings; use Closure; use Illuminate\Http\Request; @@ -11,9 +12,18 @@ use Symfony\Component\HttpFoundation\Response; /** * Two-factor for operators, when the owner has made it compulsory. * - * Off by default. The settings page itself is exempt, because it is where the - * operator sets two-factor up — gating it would leave nobody able to satisfy - * the requirement. + * Off by default. ENROLMENT (admin.two-factor-setup) is exempt, because it is + * where the operator sets two-factor up — gating it would leave nobody able + * to satisfy the requirement. Nothing else is. + * + * This used to exempt the whole of admin.settings, on the theory that it was + * "the page where two-factor is set up". That component also carries staff + * management, site visibility, network restrictions, account changes, and + * the two-factor policy switch itself — so an unenrolled operator got + * unrestricted access to all of that, not just enrolment (Codex R15, P1b). + * Enrolment has its own route and component for exactly this reason: one + * page to exempt is one decision, not eight actions each needing to + * remember to check. */ class RequireOperatorTwoFactor { @@ -29,12 +39,13 @@ class RequireOperatorTwoFactor return $next($request); } - // The one page that must stay reachable: where two-factor is enrolled. - if (\App\Support\AdminArea::routeIs('admin.settings') || $request->routeIs('admin.logout')) { + // The two pages that must stay reachable: where two-factor is + // enrolled, and signing out. + if (AdminArea::routeIs('admin.two-factor-setup') || $request->routeIs('admin.logout')) { return $next($request); } - return redirect()->route('admin.settings') + return redirect()->route('admin.two-factor-setup') ->with('status', __('admin_settings.two_factor_required')); } } diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 9951b90..fe83da6 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -4,7 +4,6 @@ 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\Operator; use App\Models\VpnPeer; @@ -15,11 +14,6 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; -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\Validate; use Livewire\Component; @@ -30,12 +24,16 @@ use Symfony\Component\HttpFoundation\IpUtils; * Operator settings: the signed-in operator's own account plus (Owner-only) * staff management. All staff mutations are capability-gated server-side and * protect the last-Owner and self-role invariants transactionally. + * + * Two-factor ENROLMENT lives on its own page, Admin\TwoFactorSetup — see + * RequireOperatorTwoFactor for why. Only the POLICY switch + * (saveTwoFactorPolicy(), below) stays here: only someone who already + * satisfies the policy should be the one changing it. */ #[Layout('layouts.admin')] class Settings extends Component { use ChangesOwnPassword; - use ConfirmsPassword; /** This page changes the signed-in OPERATOR's password, never a portal one. */ protected function passwordAccountGuard(): string @@ -43,87 +41,6 @@ class Settings extends Component 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 #[Validate('required|string|max:255')] public string $name = ''; @@ -467,8 +384,6 @@ class Settings extends Component public function render() { - $operator = Auth::guard('operator')->user(); - $staff = Operator::query() ->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::OPERATOR_ROLES)) ->with('roles') @@ -502,16 +417,6 @@ class Settings extends Component 'roles' => Operator::OPERATOR_ROLES, 'canManageStaff' => Auth::guard('operator')->user()->can('staff.manage'), '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(), ]); } } diff --git a/app/Livewire/Admin/TwoFactorSetup.php b/app/Livewire/Admin/TwoFactorSetup.php new file mode 100644 index 0000000..1b9343f --- /dev/null +++ b/app/Livewire/Admin/TwoFactorSetup.php @@ -0,0 +1,139 @@ +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(), + ]); + } +} diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index 93b136b..244664b 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -55,6 +55,11 @@ final class Navigation ['admin.mail', 'mail', 'mail', 'mail.manage'], ['admin.secrets', 'lock', 'secrets', 'secrets.manage'], ['admin.settings', 'settings', 'settings', null], + // null capability, deliberately: the compulsory two-factor + // switch (Admin\Settings::saveTwoFactorPolicy()) applies to + // every operator, so enrolment has to be reachable by every + // operator too, not only the ones who can manage the site. + ['admin.two-factor-setup', 'shield-check', 'two_factor_setup', null], ]], ]; } diff --git a/lang/de/admin.php b/lang/de/admin.php index 36d66f7..54577ea 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -24,6 +24,7 @@ return [ 'mail' => 'E-Mail', 'secrets' => 'Zugangsdaten', 'settings' => 'Einstellungen', + 'two_factor_setup' => 'Zwei-Faktor-Anmeldung', ], 'version_hint' => 'Laufende Version und Commit — das, was zuletzt erfolgreich ausgerollt wurde.', diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index 09e126b..7d7414d 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -76,37 +76,17 @@ return [ 'console_unlocked' => 'Einschränkung aufgehoben.', 'console_lock_refused' => 'Nicht eingeschränkt: :ip steht auf keiner Liste, Sie hätten sich damit ausgesperrt.', - // Two-factor policy — console.require_2fa. + // Two-factor policy — console.require_2fa. Enrolment selbst ist eine + // eigene Seite, admin.two-factor-setup (App\Livewire\Admin\TwoFactorSetup) + // — siehe RequireOperatorTwoFactor dafür, warum nur die Einrichtung und + // nicht diese ganze Seite von der erzwungenen Weiterleitung ausgenommen ist. 'saved' => 'Gespeichert.', 'two_factor_title' => 'Zwei-Faktor-Pflicht', - 'two_factor_body' => 'Gilt dann für jeden Mitarbeiter: Wer Zwei-Faktor nicht eingerichtet hat, kommt nur noch auf diese Seite.', + 'two_factor_body' => 'Gilt dann für jeden Mitarbeiter: Wer Zwei-Faktor nicht eingerichtet hat, kommt nur noch auf die Zwei-Faktor-Einrichtung.', 'two_factor_require' => 'Für alle verbindlich machen', '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.', - // 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_version' => 'Läuft', 'update_source' => 'Quelle', diff --git a/lang/de/two_factor_setup.php b/lang/de/two_factor_setup.php new file mode 100644 index 0000000..4e71e4e --- /dev/null +++ b/lang/de/two_factor_setup.php @@ -0,0 +1,22 @@ + 'Meine Zwei-Faktor-Anmeldung', + 'sub' => 'Zusätzlich zum Passwort ein Code aus einer App. Schützt Ihr Konto auch dann, wenn das Passwort irgendwo abhandenkommt.', + 'state_on' => 'Aktiv', + 'state_off' => 'Nicht eingerichtet', + 'confirm_first' => 'Bitte zuerst Ihr Passwort bestätigen — eine angemeldete Sitzung allein genügt hier nicht.', + 'confirm_button' => 'Bestätigen', + 'enable' => 'Einrichten', + 'scan' => 'Scannen Sie den Code mit Ihrer Authenticator-App und geben Sie danach die angezeigte Ziffernfolge ein.', + 'code' => 'Code aus der App', + 'activate' => 'Aktivieren', + 'code_wrong' => 'Dieser Code stimmt nicht.', + 'on' => 'Zwei-Faktor-Anmeldung ist aktiv.', + 'off' => 'Zwei-Faktor-Anmeldung wurde entfernt.', + 'off_confirm' => 'Zwei-Faktor-Anmeldung wirklich entfernen? Ihr Konto ist danach nur noch durch das Passwort geschützt.', + 'disable' => 'Entfernen', + 'new_codes' => 'Neue Wiederherstellungscodes', + 'codes_title' => 'Wiederherstellungscodes', + 'codes_hint' => 'Jetzt notieren und sicher verwahren. Sie sind der Weg zurück, wenn das Gerät verloren geht — jeder Code gilt einmal.', +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index 437f973..e5af694 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -24,6 +24,7 @@ return [ 'mail' => 'Email', 'secrets' => 'Credentials', 'settings' => 'Settings', + 'two_factor_setup' => 'Two-factor login', ], 'version_hint' => 'Running version and commit — what last deployed successfully.', diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index c0bd6c4..d7b19ff 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -76,37 +76,17 @@ return [ 'console_unlocked' => 'Restriction lifted.', 'console_lock_refused' => 'Not restricted: :ip is on no list, so this would have locked you out.', - // Two-factor policy — console.require_2fa. + // Two-factor policy — console.require_2fa. Enrolment itself is its own + // page, admin.two-factor-setup (App\Livewire\Admin\TwoFactorSetup) — see + // RequireOperatorTwoFactor for why only enrolment, and not this whole + // page, is exempt from the redirect this forces. 'saved' => 'Saved.', 'two_factor_title' => 'Two-factor requirement', - 'two_factor_body' => 'Applies to every member of staff: anyone who has not set two-factor up can reach this page and nothing else.', + 'two_factor_body' => 'Applies to every member of staff: anyone who has not set two-factor up can reach the two-factor setup page and nothing else.', 'two_factor_require' => 'Make it compulsory for everyone', '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.', - // 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_version' => 'Running', 'update_source' => 'Source', diff --git a/lang/en/two_factor_setup.php b/lang/en/two_factor_setup.php new file mode 100644 index 0000000..7162c5c --- /dev/null +++ b/lang/en/two_factor_setup.php @@ -0,0 +1,22 @@ + 'My two-factor login', + 'sub' => 'A code from an app, in addition to your password. Protects your account even if the password ends up somewhere it should not.', + 'state_on' => 'Active', + 'state_off' => 'Not set up', + 'confirm_first' => 'Please confirm your password first — a signed-in session alone is not enough here.', + 'confirm_button' => 'Confirm', + 'enable' => 'Set up', + 'scan' => 'Scan the code with your authenticator app, then enter the digits it shows.', + 'code' => 'Code from the app', + 'activate' => 'Activate', + 'code_wrong' => 'That code is not correct.', + 'on' => 'Two-factor login is active.', + 'off' => 'Two-factor login was removed.', + 'off_confirm' => 'Really remove two-factor login? Your account is then protected by the password alone.', + 'disable' => 'Remove', + 'new_codes' => 'New recovery codes', + 'codes_title' => 'Recovery codes', + '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.', +]; diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index f00ea29..2e29182 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -298,91 +298,6 @@ - {{-- 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. --}} -
-
-
-
-

{{ __('admin_settings.twofa_title') }}

- - - {{ $twoFactorOn ? __('admin_settings.twofa_state_on') : __('admin_settings.twofa_state_off') }} - -
-

{{ __('admin_settings.twofa_sub') }}

-
-
- - @if (! $passwordConfirmed) - {{-- The gate. A signed-in session is not enough to change how the - account is protected — the risk is an unlocked machine. --}} -
-

{{ __('admin_settings.twofa_confirm_first') }}

-
-
- -
- - {{ __('admin_settings.twofa_confirm_button') }} - -
-
- @elseif ($twoFactorOn) -
- - {{ __('admin_settings.twofa_new_codes') }} - - - {{ __('admin_settings.twofa_disable') }} - -
- @elseif ($twoFactorPending) -
-
{!! $twoFactorQr !!}
-
-

{{ __('admin_settings.twofa_scan') }}

-
- -
- - {{ __('admin_settings.twofa_activate') }} - -
-
- @else - - {{ __('admin_settings.twofa_enable') }} - - @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. --}} -
-

{{ __('admin_settings.twofa_codes_title') }}

-

{{ __('admin_settings.twofa_codes_hint') }}

-
    - @foreach ($recoveryCodes as $code)
  • {{ $code }}
  • @endforeach -
-
- @endif -
-

{{ __('admin_settings.account_title') }}

diff --git a/resources/views/livewire/admin/two-factor-setup.blade.php b/resources/views/livewire/admin/two-factor-setup.blade.php new file mode 100644 index 0000000..96689b9 --- /dev/null +++ b/resources/views/livewire/admin/two-factor-setup.blade.php @@ -0,0 +1,75 @@ +
+
+

{{ __('two_factor_setup.title') }}

+

{{ __('two_factor_setup.sub') }}

+
+ + {{-- Every action is re-checked server-side against a recent password + confirmation — hiding the buttons stops nobody who can post to + /livewire/update. --}} +
+ + + {{ $twoFactorOn ? __('two_factor_setup.state_on') : __('two_factor_setup.state_off') }} + + + @if (! $passwordConfirmed) + {{-- The gate. A signed-in session is not enough to change how the + account is protected — the risk is an unlocked machine. --}} + +

{{ __('two_factor_setup.confirm_first') }}

+
+
+ +
+ + {{ __('two_factor_setup.confirm_button') }} + +
+ + @elseif ($twoFactorOn) +
+ + {{ __('two_factor_setup.new_codes') }} + + + {{ __('two_factor_setup.disable') }} + +
+ @elseif ($twoFactorPending) +
+
{!! $twoFactorQr !!}
+
+

{{ __('two_factor_setup.scan') }}

+
+ +
+ + {{ __('two_factor_setup.activate') }} + +
+
+ @else + + {{ __('two_factor_setup.enable') }} + + @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. --}} +
+

{{ __('two_factor_setup.codes_title') }}

+

{{ __('two_factor_setup.codes_hint') }}

+
    + @foreach ($recoveryCodes as $code)
  • {{ $code }}
  • @endforeach +
+
+ @endif +
+
diff --git a/routes/admin.php b/routes/admin.php index ac1c484..3d377b8 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -2,6 +2,7 @@ use App\Http\Controllers\ImpersonationController; use App\Livewire\Admin; +use App\Services\Deployment\UpdateChannel; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Route; @@ -40,6 +41,9 @@ Route::get('/revenue', Admin\Revenue::class)->name('revenue'); Route::get('/mail', Admin\Mail::class)->name('mail'); Route::get('/secrets', Admin\Secrets::class)->name('secrets'); Route::get('/settings', Admin\Settings::class)->name('settings'); +// Its own route so RequireOperatorTwoFactor can exempt ENROLMENT without +// exempting the rest of admin.settings — see that middleware for why. +Route::get('/two-factor-setup', Admin\TwoFactorSetup::class)->name('two-factor-setup'); // POST so Laravel's CSRF middleware protects it, like every other state change. Route::post('/logout', function () { @@ -74,7 +78,7 @@ Route::post('/logout', function () { * failed request here is expected (it IS the restart) and the poller keeps * trying rather than giving up. */ -Route::get('/update/state', function (\App\Services\Deployment\UpdateChannel $channel) { +Route::get('/update/state', function (UpdateChannel $channel) { $state = $channel->state(); return response()->json([ diff --git a/tests/Feature/Admin/OperatorTwoFactorEnrollmentTest.php b/tests/Feature/Admin/OperatorTwoFactorEnrollmentTest.php index 33b08bb..cde5d85 100644 --- a/tests/Feature/Admin/OperatorTwoFactorEnrollmentTest.php +++ b/tests/Feature/Admin/OperatorTwoFactorEnrollmentTest.php @@ -1,6 +1,6 @@ test(Settings::class) + ->test(TwoFactorSetup::class) ->call($action) ->assertForbidden(); } @@ -38,7 +45,7 @@ it('takes a password, then sets two-factor up and confirms it with a real code', $op = operator('Support'); $page = Livewire::actingAs($op, 'operator') - ->test(Settings::class) + ->test(TwoFactorSetup::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') ->assertHasNoErrors(); @@ -61,7 +68,7 @@ it('rejects a wrong code instead of switching it on', function () { $op = operator('Support'); Livewire::actingAs($op, 'operator') - ->test(Settings::class) + ->test(TwoFactorSetup::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') ->call('enableTwoFactor') @@ -76,7 +83,7 @@ it('does not accept a wrong password, and says so without hinting', function () $op = operator('Support'); Livewire::actingAs($op, 'operator') - ->test(Settings::class) + ->test(TwoFactorSetup::class) ->set('confirmablePassword', 'falsch') ->call('confirmPassword') ->assertHasErrors('confirmablePassword') @@ -90,7 +97,7 @@ it('never puts the secret where the browser can see it', function () { $op = operator('Support'); $page = Livewire::actingAs($op, 'operator') - ->test(Settings::class) + ->test(TwoFactorSetup::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') ->call('enableTwoFactor'); @@ -107,30 +114,30 @@ it('actually renders the enrolment control on the page, not just in the componen $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 = Livewire::actingAs($op, 'operator')->test(TwoFactorSetup::class); + $page->assertSee(__('two_factor_setup.confirm_first')) + ->assertSee(__('two_factor_setup.state_off')); $page->set('confirmablePassword', 'password')->call('confirmPassword'); - $page->assertSee(__('admin_settings.twofa_enable')); + $page->assertSee(__('two_factor_setup.enable')); $page->call('enableTwoFactor'); - $page->assertSee(__('admin_settings.twofa_activate')) - ->assertSee(__('admin_settings.twofa_code')); + $page->assertSee(__('two_factor_setup.activate')) + ->assertSee(__('two_factor_setup.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')); + $page->assertSee(__('two_factor_setup.state_on')) + ->assertSee(__('two_factor_setup.codes_title')) + ->assertSee(__('two_factor_setup.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) + ->test(TwoFactorSetup::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') ->call('enableTwoFactor'); diff --git a/tests/Feature/Admin/OperatorTwoFactorPolicyTest.php b/tests/Feature/Admin/OperatorTwoFactorPolicyTest.php index dbe6e22..cca5220 100644 --- a/tests/Feature/Admin/OperatorTwoFactorPolicyTest.php +++ b/tests/Feature/Admin/OperatorTwoFactorPolicyTest.php @@ -1,6 +1,7 @@ actingAs($operator, 'operator') ->get(route('admin.overview')) - ->assertRedirect(route('admin.settings')); + ->assertRedirect(route('admin.two-factor-setup')); +}); + +it('no longer exempts admin.settings itself — only enrolment', function () { + // Codex R15 P1b: the earlier shape exempted the whole of admin.settings, + // on the theory that it was "the page where two-factor is set up". That + // component also carries staff management, site visibility, network + // restrictions, account changes, and the two-factor policy switch + // itself — so an unenrolled operator got unrestricted access to all of + // that, not just enrolment. An Owner here on purpose: staff management is + // Owner-only, so this is the account that would have the most to reach. + AppSettings::set('console.require_2fa', true); + + $operator = Operator::factory()->role('Owner')->create(); + + $this->actingAs($operator, 'operator') + ->get(route('admin.settings')) + ->assertRedirect(route('admin.two-factor-setup')); }); it('lets an operator with confirmed two-factor through', function () { @@ -60,17 +78,22 @@ it('lets an operator without two-factor genuinely enrol while the switch is on, // Bounced off an ordinary page… $this->actingAs($operator, 'operator') ->get(route('admin.overview')) - ->assertRedirect(route('admin.settings')); + ->assertRedirect(route('admin.two-factor-setup')); - // …but the settings page itself is genuinely reachable, not another - // bounce in disguise. + // …and off admin.settings too — that page is no longer the exemption… $this->actingAs($operator, 'operator') ->get(route('admin.settings')) + ->assertRedirect(route('admin.two-factor-setup')); + + // …but the two-factor setup page itself is genuinely reachable, not + // another bounce in disguise. + $this->actingAs($operator, 'operator') + ->get(route('admin.two-factor-setup')) ->assertOk(); // Enrol for real: password confirmation, then a real TOTP code. $page = Livewire::actingAs($operator, 'operator') - ->test(Settings::class) + ->test(TwoFactorSetup::class) ->set('confirmablePassword', 'password') ->call('confirmPassword') ->assertHasNoErrors() @@ -82,10 +105,15 @@ it('lets an operator without two-factor genuinely enrol while the switch is on, expect($operator->refresh()->two_factor_confirmed_at)->not->toBeNull(); - // And now the rest of the console actually opens. + // And now the rest of the console actually opens — admin.settings + // included, the very page this test proved was blocked above. $this->actingAs($operator->fresh(), 'operator') ->get(route('admin.overview')) ->assertOk(); + + $this->actingAs($operator->fresh(), 'operator') + ->get(route('admin.settings')) + ->assertOk(); }); it('allows it once your own two-factor is confirmed', function () {