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') }}

- -
- @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 () {