Let the owner make two-factor compulsory, without locking themselves out

feat/operator-identity
nexxo 2026-07-28 12:08:08 +02:00
parent 26c84e3695
commit e519653f66
8 changed files with 167 additions and 3 deletions

View File

@ -0,0 +1,40 @@
<?php
namespace App\Http\Middleware;
use App\Support\Settings;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
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.
*/
class RequireOperatorTwoFactor
{
public function handle(Request $request, Closure $next): Response
{
if (! Settings::bool('console.require_2fa', false)) {
return $next($request);
}
$operator = Auth::guard('operator')->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'));
}
}

View File

@ -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.

View File

@ -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

View File

@ -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',

View File

@ -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',

View File

@ -254,6 +254,27 @@
</div>
@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)
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:45ms]">
<h2 class="font-semibold text-ink">{{ __('admin_settings.two_factor_title') }}</h2>
<p class="mt-1 max-w-xl text-sm text-muted">{{ __('admin_settings.two_factor_body') }}</p>
<form wire:submit="saveTwoFactorPolicy" class="mt-4 flex flex-wrap items-center justify-between gap-3">
<label class="flex items-center gap-2 text-sm text-body">
<input type="checkbox" wire:model="requireTwoFactor" class="size-4 rounded border-line-strong text-accent" />
{{ __('admin_settings.two_factor_require') }}
</label>
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="saveTwoFactorPolicy">
{{ __('admin_settings.save') }}
</x-ui.button>
</form>
@error('requireTwoFactor')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
</div>
@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. --}}

View File

@ -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'));

View File

@ -0,0 +1,62 @@
<?php
use App\Livewire\Admin\Settings;
use App\Models\Operator;
use App\Support\Settings as AppSettings;
use Livewire\Livewire;
it('is voluntary by default', function () {
expect(AppSettings::bool('console.require_2fa', false))->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();
});