Let the owner make two-factor compulsory, without locking themselves out
parent
26c84e3695
commit
e519653f66
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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. --}}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue