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_sub') }}
-{{ __('admin_settings.twofa_codes_title') }}
-{{ __('admin_settings.twofa_codes_hint') }}
-