diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 7636732..9951b90 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -2,20 +2,29 @@ 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; +use App\Provisioning\Jobs\ApplyVpnPeer; use App\Services\Deployment\UpdateChannel; use App\Support\Settings as AppSettings; -use App\Provisioning\Jobs\ApplyVpnPeer; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; 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\Validate; 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) @@ -25,7 +34,8 @@ use Livewire\Component; #[Layout('layouts.admin')] 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. */ protected function passwordAccountGuard(): string @@ -33,6 +43,87 @@ 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 = ''; @@ -262,7 +353,7 @@ class Settings extends Component $value = trim($this->consoleIp); - if (! \App\Http\Middleware\RestrictConsoleNetwork::isNetwork($value)) { + if (! RestrictConsoleNetwork::isNetwork($value)) { $this->addError('consoleIp', __('admin_settings.console_ip_invalid')); return; @@ -298,8 +389,8 @@ class Settings extends Component $ip = (string) request()->ip(); - if (\App\Http\Middleware\RestrictConsoleNetwork::isRestricted() - && ! \App\Http\Middleware\RestrictConsoleNetwork::wouldStillAllow($ip, $list)) { + if (RestrictConsoleNetwork::isRestricted() + && ! RestrictConsoleNetwork::wouldStillAllow($ip, $list)) { $this->dispatch('notify', message: __('admin_settings.console_ip_last', ['ip' => $ip])); return; @@ -320,9 +411,9 @@ class Settings extends Component { $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()])); return; @@ -355,7 +446,6 @@ class Settings extends Component $this->dispatch('notify', message: __('admin_settings.saved')); } - /** * Ask the host-side agent to update this installation. * @@ -377,6 +467,8 @@ 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') @@ -396,13 +488,13 @@ class Settings extends Component 'sitePublic' => AppSettings::bool('site.public', true), 'canManageSite' => Auth::guard('operator')->user()?->can('site.manage') ?? false, // 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(), (array) config('admin_access.trusted_ranges', []), ), // 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. - 'consoleRestricted' => \App\Http\Middleware\RestrictConsoleNetwork::isRestricted(), + 'consoleRestricted' => RestrictConsoleNetwork::isRestricted(), 'consoleIps' => (array) AppSettings::get('console.allowed_ips', []), 'consoleVpnRanges' => (array) config('admin_access.trusted_ranges', []), 'viewerIp' => (string) request()->ip(), @@ -410,6 +502,16 @@ 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/lang/de/admin_settings.php b/lang/de/admin_settings.php index dfa07ff..09e126b 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -20,7 +20,6 @@ return [ 'account_title' => 'Mein Konto', 'name' => 'Name', 'email' => 'E-Mail', - 'twofa_hint' => 'Passwort & Zwei-Faktor-Authentifizierung folgen in einem separaten Sicherheitsbereich.', 'account_saved' => 'Konto aktualisiert.', '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_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/en/admin_settings.php b/lang/en/admin_settings.php index f00a7ce..c0bd6c4 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -20,7 +20,6 @@ return [ 'account_title' => 'My account', 'name' => 'Name', 'email' => 'Email', - 'twofa_hint' => 'Password & two-factor authentication follow in a separate security area.', 'account_saved' => 'Account updated.', '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_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/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index 782e625..f00ea29 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -298,13 +298,97 @@ + {{-- 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') }}
+