From e519653f665ff2efe01f247b1975ad85a0e6814b Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 28 Jul 2026 12:08:08 +0200 Subject: [PATCH] Let the owner make two-factor compulsory, without locking themselves out --- .../Middleware/RequireOperatorTwoFactor.php | 40 ++++++++++++ app/Livewire/Admin/Settings.php | 24 +++++++ bootstrap/app.php | 1 + lang/de/admin_settings.php | 8 +++ lang/en/admin_settings.php | 8 +++ .../views/livewire/admin/settings.blade.php | 21 +++++++ routes/web.php | 6 +- .../Admin/OperatorTwoFactorPolicyTest.php | 62 +++++++++++++++++++ 8 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 app/Http/Middleware/RequireOperatorTwoFactor.php create mode 100644 tests/Feature/Admin/OperatorTwoFactorPolicyTest.php diff --git a/app/Http/Middleware/RequireOperatorTwoFactor.php b/app/Http/Middleware/RequireOperatorTwoFactor.php new file mode 100644 index 0000000..69b1c56 --- /dev/null +++ b/app/Http/Middleware/RequireOperatorTwoFactor.php @@ -0,0 +1,40 @@ +user(); + + if ($operator === null || $operator->two_factor_confirmed_at !== null) { + 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')) { + return $next($request); + } + + return redirect()->route('admin.settings') + ->with('status', __('admin_settings.two_factor_required')); + } +} diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 9c8e86a..7636732 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -59,6 +59,7 @@ class Settings extends Component { $this->name = Auth::guard('operator')->user()->name; $this->email = Auth::guard('operator')->user()->email; + $this->requireTwoFactor = AppSettings::bool('console.require_2fa', false); } /** @@ -331,6 +332,29 @@ class Settings extends Component $this->dispatch('notify', message: __($on ? 'admin_settings.console_locked' : 'admin_settings.console_unlocked')); } + /** Whether two-factor is compulsory for every operator on the console. */ + public bool $requireTwoFactor = false; + + /** + * Make two-factor compulsory for every operator, or make it voluntary again. + * + * The lock-out guard, same shape as console.allowed_ips: the page that + * could switch this back off sits behind the switch. + */ + public function saveTwoFactorPolicy(): void + { + $this->authorize('site.manage'); + + if ($this->requireTwoFactor && Auth::guard('operator')->user()?->two_factor_confirmed_at === null) { + $this->addError('requireTwoFactor', __('admin_settings.two_factor_self_first')); + + return; + } + + AppSettings::set('console.require_2fa', $this->requireTwoFactor); + $this->dispatch('notify', message: __('admin_settings.saved')); + } + /** * Ask the host-side agent to update this installation. diff --git a/bootstrap/app.php b/bootstrap/app.php index e776c6a..1f3cff6 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -17,6 +17,7 @@ return Application::configure(basePath: dirname(__DIR__)) 'admin' => \App\Http\Middleware\EnsureAdmin::class, 'admin.host' => \App\Http\Middleware\RestrictAdminHost::class, 'customer.active' => \App\Http\Middleware\EnsureCustomerActive::class, + 'operator.2fa' => \App\Http\Middleware\RequireOperatorTwoFactor::class, ]); // Runs before the whole route stack (incl. `auth`), so /admin on a public diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index 94c9f34..324e0d8 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -77,6 +77,14 @@ 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. + 'saved' => 'Gespeichert.', + 'two_factor_title' => 'Zwei-Faktor-Pflicht', + 'two_factor_body' => 'Verbindlich schaltet Zwei-Faktor für jeden Operator scharf — ohne bestätigte Zwei-Faktor-Bestätigung bleibt nur noch diese Seite erreichbar.', + '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.', + '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 2c7b2c4..eddad23 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -77,6 +77,14 @@ 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. + 'saved' => 'Saved.', + 'two_factor_title' => 'Two-factor requirement', + 'two_factor_body' => 'Making it compulsory enforces two-factor for every operator — without a confirmed two-factor setup, only this page stays reachable.', + '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.', + '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 296cd21..782e625 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -254,6 +254,27 @@ @endif + {{-- Two-factor policy — voluntary by default, the Owner can make it + compulsory. The switch refuses to turn on while the operator flipping + it has no confirmed two-factor themselves: the page that would turn it + back off sits behind the switch. --}} + @if ($canManageSite) +
+

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

+

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

+ +
+ + + {{ __('admin_settings.save') }} + +
+ @error('requireTwoFactor')

{{ $message }}

@enderror +
+ @endif {{-- Own password. There was no way to change one at all: an account created with a generated password kept it until someone opened a shell. --}} diff --git a/routes/web.php b/routes/web.php index 6aa6560..298fbfc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -87,7 +87,7 @@ if (AdminArea::isExclusive()) { ->group(base_path('routes/admin-guest.php')); Route::domain($alternate) - ->middleware(['admin.host', 'auth:operator,web', 'admin']) + ->middleware(['admin.host', 'auth:operator,web', 'admin', 'operator.2fa']) ->prefix(AdminArea::prefix()) ->name("admin.via{$index}.") ->group(base_path('routes/admin.php')); @@ -100,7 +100,7 @@ if (AdminArea::isExclusive()) { ->group(base_path('routes/admin-guest.php')); Route::domain($canonical) - ->middleware(['admin.host', 'auth:operator,web', 'admin']) + ->middleware(['admin.host', 'auth:operator,web', 'admin', 'operator.2fa']) ->prefix(AdminArea::prefix()) ->name('admin.') ->group(base_path('routes/admin.php')); @@ -112,7 +112,7 @@ if (AdminArea::isExclusive()) { ->name('admin.') ->group(base_path('routes/admin-guest.php')); - Route::middleware(['admin.host', 'auth:operator,web', 'admin']) + Route::middleware(['admin.host', 'auth:operator,web', 'admin', 'operator.2fa']) ->prefix(AdminArea::prefix()) ->name('admin.') ->group(base_path('routes/admin.php')); diff --git a/tests/Feature/Admin/OperatorTwoFactorPolicyTest.php b/tests/Feature/Admin/OperatorTwoFactorPolicyTest.php new file mode 100644 index 0000000..2b25a4e --- /dev/null +++ b/tests/Feature/Admin/OperatorTwoFactorPolicyTest.php @@ -0,0 +1,62 @@ +toBeFalse(); + + $operator = Operator::factory()->role('Owner')->create(); + + $this->actingAs($operator, 'operator')->get(route('admin.overview'))->assertOk(); +}); + +it('sends an operator without two-factor to the setup page once the switch is on', function () { + AppSettings::set('console.require_2fa', true); + + $operator = Operator::factory()->role('Owner')->create(); + + $this->actingAs($operator, 'operator') + ->get(route('admin.overview')) + ->assertRedirect(route('admin.settings')); +}); + +it('lets an operator with confirmed two-factor through', function () { + AppSettings::set('console.require_2fa', true); + + $operator = Operator::factory()->role('Owner')->create([ + 'two_factor_secret' => encrypt('JBSWY3DPEHPK3PXP'), + 'two_factor_confirmed_at' => now(), + ]); + + $this->actingAs($operator, 'operator')->get(route('admin.overview'))->assertOk(); +}); + +it('refuses to switch it on while your own account has no two-factor', function () { + // The lock-out this exists to prevent: the page that would turn it back off + // sits behind the switch. Same shape as console.allowed_ips. + $operator = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($operator, 'operator')->test(Settings::class) + ->set('requireTwoFactor', true) + ->call('saveTwoFactorPolicy') + ->assertHasErrors('requireTwoFactor'); + + expect(AppSettings::bool('console.require_2fa', false))->toBeFalse(); +}); + +it('allows it once your own two-factor is confirmed', function () { + $operator = Operator::factory()->role('Owner')->create([ + 'two_factor_secret' => encrypt('JBSWY3DPEHPK3PXP'), + 'two_factor_confirmed_at' => now(), + ]); + + Livewire::actingAs($operator, 'operator')->test(Settings::class) + ->set('requireTwoFactor', true) + ->call('saveTwoFactorPolicy') + ->assertHasNoErrors(); + + expect(AppSettings::bool('console.require_2fa', false))->toBeTrue(); +});