Give the console a front door of its own
parent
513cadc5ce
commit
3f318c3f6a
|
|
@ -0,0 +1,103 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Auth;
|
||||||
|
|
||||||
|
use App\Models\Operator;
|
||||||
|
use App\Support\AdminArea;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console's own sign-in.
|
||||||
|
*
|
||||||
|
* Not Fortify: Fortify binds to exactly one guard, and hanging both the portal
|
||||||
|
* and the console off it would be the shared login this separation exists to
|
||||||
|
* end. The two-factor MECHANICS are still Fortify's — the same
|
||||||
|
* TwoFactorAuthenticatable trait and the same TOTP provider — only the flow is
|
||||||
|
* ours.
|
||||||
|
*/
|
||||||
|
#[Layout('layouts.portal')]
|
||||||
|
class OperatorLogin extends Component
|
||||||
|
{
|
||||||
|
public string $email = '';
|
||||||
|
|
||||||
|
public string $password = '';
|
||||||
|
|
||||||
|
public bool $remember = false;
|
||||||
|
|
||||||
|
public function authenticate(): void
|
||||||
|
{
|
||||||
|
// Rules on the action: a #[Validate] attribute on a property applies
|
||||||
|
// class-wide and would be dragged into any other action added later.
|
||||||
|
//
|
||||||
|
// password is NOT 'required': a failed attempt resets it below, and a
|
||||||
|
// retry (or a brute-force loop) submitting on top of that empty value
|
||||||
|
// must still count against the rate limiter instead of stopping short
|
||||||
|
// at a validation error keyed 'password' — which would never accumulate
|
||||||
|
// toward the throttle at all, five wrong-password submissions in a row
|
||||||
|
// would never actually throttle. An empty password fails Hash::check()
|
||||||
|
// like any other wrong one and gets the same generic message.
|
||||||
|
$this->validate([
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
'password' => ['string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$key = 'operator-login:'.mb_strtolower($this->email).'|'.request()->ip();
|
||||||
|
|
||||||
|
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => __('auth.throttle', ['seconds' => RateLimiter::availableIn($key)]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$operator = Operator::where('email', $this->email)->first();
|
||||||
|
|
||||||
|
// One message for every failure — a different message for "no such
|
||||||
|
// account" tells a stranger which addresses are operators.
|
||||||
|
if ($operator === null
|
||||||
|
|| ! Hash::check($this->password, $operator->password)
|
||||||
|
|| ! $operator->isActive()
|
||||||
|
|| ! $operator->isOperator()) {
|
||||||
|
RateLimiter::hit($key, 60);
|
||||||
|
$this->reset('password');
|
||||||
|
|
||||||
|
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::clear($key);
|
||||||
|
|
||||||
|
if ($operator->two_factor_confirmed_at !== null) {
|
||||||
|
// Not signed in yet. The id is parked in the session and the guard
|
||||||
|
// stays untouched until the code checks out.
|
||||||
|
session(['operator.2fa.id' => $operator->id, 'operator.2fa.remember' => $this->remember]);
|
||||||
|
$this->redirectRoute('admin.two-factor', navigate: false);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->completeLoginAndRedirect($operator, $this->remember);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared with the two-factor challenge, so both exits behave identically. */
|
||||||
|
public static function completeLogin(Operator $operator, bool $remember): void
|
||||||
|
{
|
||||||
|
Auth::guard('operator')->login($operator, $remember);
|
||||||
|
session()->regenerate();
|
||||||
|
$operator->forceFill(['last_login_at' => now()])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function completeLoginAndRedirect(Operator $operator, bool $remember): void
|
||||||
|
{
|
||||||
|
self::completeLogin($operator, $remember);
|
||||||
|
$this->redirect(AdminArea::home(), navigate: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.auth.operator-login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Auth;
|
||||||
|
|
||||||
|
use App\Models\Operator;
|
||||||
|
use App\Support\AdminArea;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The operator's two-factor step.
|
||||||
|
*
|
||||||
|
* Fortify's TOTP provider does the arithmetic — there is no second
|
||||||
|
* implementation of the algorithm here, only a second flow, because Fortify's
|
||||||
|
* own flow is bound to the web guard.
|
||||||
|
*/
|
||||||
|
#[Layout('layouts.portal')]
|
||||||
|
class OperatorTwoFactorChallenge extends Component
|
||||||
|
{
|
||||||
|
public string $code = '';
|
||||||
|
|
||||||
|
public string $recoveryCode = '';
|
||||||
|
|
||||||
|
public bool $usingRecovery = false;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
// Reachable only between password and code. Landing here directly is a
|
||||||
|
// dead end, not a way in.
|
||||||
|
abort_if(! session()->has('operator.2fa.id'), 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(): void
|
||||||
|
{
|
||||||
|
$operator = Operator::find(session('operator.2fa.id'));
|
||||||
|
abort_if($operator === null, 403);
|
||||||
|
|
||||||
|
$key = 'operator-2fa:'.$operator->id.'|'.request()->ip();
|
||||||
|
|
||||||
|
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'code' => __('auth.throttle', ['seconds' => RateLimiter::availableIn($key)]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->passes($operator)) {
|
||||||
|
RateLimiter::hit($key, 60);
|
||||||
|
$this->reset('code', 'recoveryCode');
|
||||||
|
|
||||||
|
throw ValidationException::withMessages(['code' => __('auth.twofa_invalid')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::clear($key);
|
||||||
|
|
||||||
|
$remember = (bool) session('operator.2fa.remember', false);
|
||||||
|
session()->forget(['operator.2fa.id', 'operator.2fa.remember']);
|
||||||
|
|
||||||
|
OperatorLogin::completeLogin($operator, $remember);
|
||||||
|
$this->redirect(AdminArea::home(), navigate: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function passes(Operator $operator): bool
|
||||||
|
{
|
||||||
|
if ($this->usingRecovery) {
|
||||||
|
$codes = json_decode(decrypt($operator->two_factor_recovery_codes), true) ?: [];
|
||||||
|
|
||||||
|
if (! in_array($this->recoveryCode, $codes, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A recovery code is single use — leaving it valid turns a one-time
|
||||||
|
// escape hatch into a second password.
|
||||||
|
$operator->forceFill([
|
||||||
|
'two_factor_recovery_codes' => encrypt(json_encode(array_values(
|
||||||
|
array_diff($codes, [$this->recoveryCode])
|
||||||
|
))),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(TwoFactorAuthenticationProvider::class)
|
||||||
|
->verify(decrypt($operator->two_factor_secret), $this->code);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.auth.operator-two-factor-challenge');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,8 @@ return [
|
||||||
// Login
|
// Login
|
||||||
'login_title' => 'Bei CluPilot anmelden',
|
'login_title' => 'Bei CluPilot anmelden',
|
||||||
'login_subtitle' => 'Melden Sie sich mit Ihren Zugangsdaten an.',
|
'login_subtitle' => 'Melden Sie sich mit Ihren Zugangsdaten an.',
|
||||||
|
'console_login_title' => 'Konsole',
|
||||||
|
'console_login_subtitle' => 'Anmeldung für Betreiber.',
|
||||||
'email' => 'E-Mail',
|
'email' => 'E-Mail',
|
||||||
'password_label' => 'Passwort',
|
'password_label' => 'Passwort',
|
||||||
'remember' => 'Angemeldet bleiben',
|
'remember' => 'Angemeldet bleiben',
|
||||||
|
|
@ -53,6 +55,7 @@ return [
|
||||||
'verify' => 'Bestätigen',
|
'verify' => 'Bestätigen',
|
||||||
'use_recovery' => 'Wiederherstellungscode verwenden',
|
'use_recovery' => 'Wiederherstellungscode verwenden',
|
||||||
'use_otp' => 'Authenticator-Code verwenden',
|
'use_otp' => 'Authenticator-Code verwenden',
|
||||||
|
'twofa_invalid' => 'Der Code stimmt nicht.',
|
||||||
'account_suspended' => 'Ihr Konto ist gesperrt. Bitte kontaktieren Sie den Support.',
|
'account_suspended' => 'Ihr Konto ist gesperrt. Bitte kontaktieren Sie den Support.',
|
||||||
'account_closed' => 'Dieses Konto wurde geschlossen.',
|
'account_closed' => 'Dieses Konto wurde geschlossen.',
|
||||||
'not_an_operator' => 'Dieses Konto hat keinen Zugang zur Konsole.',
|
'not_an_operator' => 'Dieses Konto hat keinen Zugang zur Konsole.',
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ return [
|
||||||
// Login
|
// Login
|
||||||
'login_title' => 'Sign in to CluPilot',
|
'login_title' => 'Sign in to CluPilot',
|
||||||
'login_subtitle' => 'Sign in with your credentials.',
|
'login_subtitle' => 'Sign in with your credentials.',
|
||||||
|
'console_login_title' => 'Console',
|
||||||
|
'console_login_subtitle' => 'Sign-in for operators.',
|
||||||
'email' => 'Email',
|
'email' => 'Email',
|
||||||
'password_label' => 'Password',
|
'password_label' => 'Password',
|
||||||
'remember' => 'Stay signed in',
|
'remember' => 'Stay signed in',
|
||||||
|
|
@ -53,6 +55,7 @@ return [
|
||||||
'verify' => 'Verify',
|
'verify' => 'Verify',
|
||||||
'use_recovery' => 'Use a recovery code',
|
'use_recovery' => 'Use a recovery code',
|
||||||
'use_otp' => 'Use authenticator code',
|
'use_otp' => 'Use authenticator code',
|
||||||
|
'twofa_invalid' => 'That code does not match.',
|
||||||
'account_suspended' => 'Your account is suspended. Please contact support.',
|
'account_suspended' => 'Your account is suspended. Please contact support.',
|
||||||
'account_closed' => 'This account has been closed.',
|
'account_closed' => 'This account has been closed.',
|
||||||
'not_an_operator' => 'This account has no access to the console.',
|
'not_an_operator' => 'This account has no access to the console.',
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,18 @@
|
||||||
'label' => null,
|
'label' => null,
|
||||||
])
|
])
|
||||||
{{-- Segmented one-time-code input. Alpine keeps the visible segments; a hidden
|
{{-- Segmented one-time-code input. Alpine keeps the visible segments; a hidden
|
||||||
field carries the joined value for the native form POST. --}}
|
field carries the joined value for the native form POST — or, inside a
|
||||||
|
Livewire form, for wire:model. wire:model needs both the attribute (so
|
||||||
|
Livewire's directive scan finds it — hence spreading $attributes here) and
|
||||||
|
a real "input" event: Alpine's :value binding only ever writes the DOM
|
||||||
|
property, which fires nothing on its own. --}}
|
||||||
<div
|
<div
|
||||||
x-data="otpInput({ length: {{ (int) $length }} })"
|
x-data="otpInput({ length: {{ (int) $length }} })"
|
||||||
class="flex gap-2"
|
class="flex gap-2"
|
||||||
role="group"
|
role="group"
|
||||||
@if ($label) aria-label="{{ $label }}" @endif
|
@if ($label) aria-label="{{ $label }}" @endif
|
||||||
>
|
>
|
||||||
<input type="hidden" name="{{ $name }}" :value="value">
|
<input type="hidden" name="{{ $name }}" :value="value" x-effect="$el.dispatchEvent(new Event('input', { bubbles: true }))" {{ $attributes }}>
|
||||||
<template x-for="(digit, i) in digits" :key="i">
|
<template x-for="(digit, i) in digits" :key="i">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
<x-ui.icon name="chevron-down" class="size-4 text-muted" />
|
<x-ui.icon name="chevron-down" class="size-4 text-muted" />
|
||||||
</button>
|
</button>
|
||||||
<div x-show="open" x-cloak @click.outside="open = false" class="absolute right-0 mt-1 w-48 overflow-hidden rounded-lg border border-line bg-surface py-1 shadow-md">
|
<div x-show="open" x-cloak @click.outside="open = false" class="absolute right-0 mt-1 w-48 overflow-hidden rounded-lg border border-line bg-surface py-1 shadow-md">
|
||||||
<form method="POST" action="{{ route('logout') }}">
|
<form method="POST" action="{{ route('admin.logout') }}">
|
||||||
@csrf
|
@csrf
|
||||||
<button type="submit" class="flex min-h-11 w-full items-center gap-2.5 px-3.5 text-left text-sm text-body hover:bg-surface-hover">
|
<button type="submit" class="flex min-h-11 w-full items-center gap-2.5 px-3.5 text-left text-sm text-body hover:bg-surface-hover">
|
||||||
<x-ui.icon name="log-out" class="size-4" />{{ __('dashboard.logout') }}
|
<x-ui.icon name="log-out" class="size-4" />{{ __('dashboard.logout') }}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
<div class="flex min-h-screen items-center justify-center bg-bg px-6 py-12">
|
||||||
|
<div class="w-full max-w-sm animate-rise">
|
||||||
|
<div class="mb-9 flex items-center gap-2.5">
|
||||||
|
<x-ui.logo class="size-8" />
|
||||||
|
<span class="text-lg font-bold tracking-tight text-ink">Clu<span class="text-accent-text">Pilot</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold leading-tight tracking-tight text-ink">{{ __('auth.console_login_title') }}</h1>
|
||||||
|
<p class="mt-2 text-sm text-muted">{{ __('auth.console_login_subtitle') }}</p>
|
||||||
|
|
||||||
|
<form wire:submit="authenticate" class="mt-8 space-y-5">
|
||||||
|
<x-ui.input name="email" type="email" wire:model="email" :label="__('auth.email')" autocomplete="username" autofocus required />
|
||||||
|
<x-ui.input name="password" type="password" wire:model="password" :label="__('auth.password_label')" autocomplete="current-password" required />
|
||||||
|
<x-ui.checkbox name="remember" wire:model="remember" :label="__('auth.remember')" value="1" />
|
||||||
|
<x-ui.button type="submit" variant="primary" size="md" class="w-full">{{ __('auth.sign_in') }}</x-ui.button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<div class="flex min-h-screen items-center justify-center bg-bg px-6 py-12">
|
||||||
|
<div class="w-full max-w-sm animate-rise">
|
||||||
|
<div class="mb-9 flex items-center gap-2.5">
|
||||||
|
<x-ui.logo class="size-8" />
|
||||||
|
<span class="text-lg font-bold tracking-tight text-ink">Clu<span class="text-accent-text">Pilot</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold leading-tight tracking-tight text-ink">{{ __('auth.twofa_title') }}</h1>
|
||||||
|
<p class="mt-2 text-sm text-muted">
|
||||||
|
{{ $usingRecovery ? __('auth.twofa_recovery_subtitle') : __('auth.twofa_subtitle') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@error('code')
|
||||||
|
<x-ui.alert variant="danger" class="mt-4">{{ $message }}</x-ui.alert>
|
||||||
|
@enderror
|
||||||
|
|
||||||
|
<form wire:submit="verify" class="mt-8 space-y-5">
|
||||||
|
@if ($usingRecovery)
|
||||||
|
<x-ui.input name="recoveryCode" type="text" wire:model="recoveryCode" :label="__('auth.recovery_code')" autocomplete="one-time-code" autofocus />
|
||||||
|
@else
|
||||||
|
<x-ui.otp-input name="code" wire:model="code" :label="__('auth.otp_digit')" />
|
||||||
|
@endif
|
||||||
|
<x-ui.button type="submit" variant="primary" size="md" class="w-full">{{ __('auth.verify') }}</x-ui.button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button type="button" class="mt-4 text-sm font-medium text-accent-text hover:underline" wire:click="$toggle('usingRecovery')">
|
||||||
|
{{ $usingRecovery ? __('auth.use_otp') : __('auth.use_recovery') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Auth\OperatorLogin;
|
||||||
|
use App\Livewire\Auth\OperatorTwoFactorChallenge;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
/*
|
||||||
|
| The console's own front door.
|
||||||
|
|
|
||||||
|
| Registered before the authenticated console routes so that /login on the
|
||||||
|
| console host resolves here, and NOT to the portal's sign-in page — which is
|
||||||
|
| what put a "Registrieren" link in front of an operator and a 404 behind it.
|
||||||
|
*/
|
||||||
|
Route::get('/login', OperatorLogin::class)->name('login');
|
||||||
|
Route::get('/two-factor', OperatorTwoFactorChallenge::class)->name('two-factor');
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use App\Http\Controllers\ImpersonationController;
|
use App\Http\Controllers\ImpersonationController;
|
||||||
use App\Livewire\Admin;
|
use App\Livewire\Admin;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -40,6 +41,15 @@ Route::get('/mail', Admin\Mail::class)->name('mail');
|
||||||
Route::get('/secrets', Admin\Secrets::class)->name('secrets');
|
Route::get('/secrets', Admin\Secrets::class)->name('secrets');
|
||||||
Route::get('/settings', Admin\Settings::class)->name('settings');
|
Route::get('/settings', Admin\Settings::class)->name('settings');
|
||||||
|
|
||||||
|
// POST so Laravel's CSRF middleware protects it, like every other state change.
|
||||||
|
Route::post('/logout', function () {
|
||||||
|
Auth::guard('operator')->logout();
|
||||||
|
session()->invalidate();
|
||||||
|
session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()->route('admin.login');
|
||||||
|
})->name('logout');
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* A deployment restarts the very application that is watching it.
|
* A deployment restarts the very application that is watching it.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,39 @@ use Illuminate\Support\Facades\Route;
|
||||||
| Where it mounts is AdminArea's call: `/` on the console hostname where it has
|
| Where it mounts is AdminArea's call: `/` on the console hostname where it has
|
||||||
| one to itself, `/admin` on any host otherwise. The names stay `admin.*` in
|
| one to itself, `/admin` on any host otherwise. The names stay `admin.*` in
|
||||||
| both modes, so nothing that generates a URL has to know which mode it is in.
|
| both modes, so nothing that generates a URL has to know which mode it is in.
|
||||||
|
|
|
||||||
|
| Two groups, not one: a guest group (routes/admin-guest.php — /login and
|
||||||
|
| /two-factor) reachable BEFORE 'auth' runs, and the authenticated group
|
||||||
|
| (routes/admin.php) behind it. In exclusive mode the guest group is bound to
|
||||||
|
| every accepted hostname exactly like the authenticated one below — canonical
|
||||||
|
| AND alternates — because signing in is exactly what someone locked out on a
|
||||||
|
| recovery hostname needs to do. This also makes Laravel's OWN redirect-to-
|
||||||
|
| login self-correct across hosts: Authenticate::redirectTo() always calls
|
||||||
|
| route('login') by name, never route('admin.login'), but that name is bound
|
||||||
|
| to no domain, so it resolves against whatever host the current request is
|
||||||
|
| on — landing back on THIS host's own /login, which these domain-bound
|
||||||
|
| registrations make the console's, not the portal's.
|
||||||
|
|
|
||||||
|
| The authenticated group checks 'auth:operator,web', not bare 'auth'. Bare
|
||||||
|
| 'auth' resolves whichever guard is CURRENTLY the default (config('auth.
|
||||||
|
| defaults.guard'), 'web' unless something changes it) — and signing in here
|
||||||
|
| only touches the 'operator' guard, never 'web'. An operator who just
|
||||||
|
| completed OperatorLogin would fail a bare 'auth' check and bounce straight
|
||||||
|
| back to the login they just used.
|
||||||
|
|
|
||||||
|
| 'operator' is listed FIRST, not merely included: Authenticate::authenticate()
|
||||||
|
| calls Auth::shouldUse() on whichever guard matches first, and a person can
|
||||||
|
| genuinely hold both a portal AND a console session at once in the same
|
||||||
|
| browser (config/auth.php — separate session keys, by design). On a CONSOLE
|
||||||
|
| route their operator identity has to be the one every bare auth()->user() or
|
||||||
|
| Gate::authorize() call resolves for the rest of the request, not whichever
|
||||||
|
| guard happened to be checked first. 'web' stays in the list behind it purely
|
||||||
|
| so a web-only customer at the console door still passes 'auth' (as
|
||||||
|
| themselves) and reaches EnsureAdmin's proper 403 — dropping 'web' entirely
|
||||||
|
| would turn that into a redirect instead. (Tests never caught either gap:
|
||||||
|
| actingAs($x, 'operator') flips the default guard via Auth::shouldUse()
|
||||||
|
| regardless of list order, which masks it. Found by mutation-testing
|
||||||
|
| ConfirmsPassword's guard resolution, not by a failing test.)
|
||||||
*/
|
*/
|
||||||
if (AdminArea::isExclusive()) {
|
if (AdminArea::isExclusive()) {
|
||||||
// Once per accepted hostname, canonical LAST.
|
// Once per accepted hostname, canonical LAST.
|
||||||
|
|
@ -44,22 +77,42 @@ if (AdminArea::isExclusive()) {
|
||||||
$canonical = array_shift($hosts);
|
$canonical = array_shift($hosts);
|
||||||
|
|
||||||
foreach ($hosts as $index => $alternate) {
|
foreach ($hosts as $index => $alternate) {
|
||||||
|
// Guest FIRST: /login has to resolve to the console's own sign-in
|
||||||
|
// before 'auth' ever runs, on every recovery hostname as much as on
|
||||||
|
// the canonical one — that IS the recovery path.
|
||||||
Route::domain($alternate)
|
Route::domain($alternate)
|
||||||
->middleware(['admin.host', 'auth', 'admin'])
|
->middleware(['admin.host', 'guest:operator'])
|
||||||
|
->prefix(AdminArea::prefix())
|
||||||
|
->name("admin.via{$index}.")
|
||||||
|
->group(base_path('routes/admin-guest.php'));
|
||||||
|
|
||||||
|
Route::domain($alternate)
|
||||||
|
->middleware(['admin.host', 'auth:operator,web', 'admin'])
|
||||||
->prefix(AdminArea::prefix())
|
->prefix(AdminArea::prefix())
|
||||||
->name("admin.via{$index}.")
|
->name("admin.via{$index}.")
|
||||||
->group(base_path('routes/admin.php'));
|
->group(base_path('routes/admin.php'));
|
||||||
}
|
}
|
||||||
|
|
||||||
Route::domain($canonical)
|
Route::domain($canonical)
|
||||||
->middleware(['admin.host', 'auth', 'admin'])
|
->middleware(['admin.host', 'guest:operator'])
|
||||||
|
->prefix(AdminArea::prefix())
|
||||||
|
->name('admin.')
|
||||||
|
->group(base_path('routes/admin-guest.php'));
|
||||||
|
|
||||||
|
Route::domain($canonical)
|
||||||
|
->middleware(['admin.host', 'auth:operator,web', 'admin'])
|
||||||
->prefix(AdminArea::prefix())
|
->prefix(AdminArea::prefix())
|
||||||
->name('admin.')
|
->name('admin.')
|
||||||
->group(base_path('routes/admin.php'));
|
->group(base_path('routes/admin.php'));
|
||||||
} else {
|
} else {
|
||||||
// Shared with the portal: no hostname binding, and the /admin prefix keeps
|
// Shared with the portal: no hostname binding, and the /admin prefix keeps
|
||||||
// the two apart by path.
|
// the two apart by path.
|
||||||
Route::middleware(['admin.host', 'auth', 'admin'])
|
Route::middleware(['admin.host', 'guest:operator'])
|
||||||
|
->prefix(AdminArea::prefix())
|
||||||
|
->name('admin.')
|
||||||
|
->group(base_path('routes/admin-guest.php'));
|
||||||
|
|
||||||
|
Route::middleware(['admin.host', 'auth:operator,web', 'admin'])
|
||||||
->prefix(AdminArea::prefix())
|
->prefix(AdminArea::prefix())
|
||||||
->name('admin.')
|
->name('admin.')
|
||||||
->group(base_path('routes/admin.php'));
|
->group(base_path('routes/admin.php'));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Auth\OperatorLogin;
|
||||||
|
use App\Models\Operator;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('offers no way to register — an operator account is not self-served', function () {
|
||||||
|
$this->get(route('admin.login'))->assertOk()
|
||||||
|
->assertDontSee(route('register'))
|
||||||
|
->assertDontSee(__('auth.sign_up'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signs an operator in on the operator guard', function () {
|
||||||
|
$operator = Operator::factory()->role('Owner')->create(['password' => 'richtig-langes-pw']);
|
||||||
|
|
||||||
|
Livewire::test(OperatorLogin::class)
|
||||||
|
->set('email', $operator->email)
|
||||||
|
->set('password', 'richtig-langes-pw')
|
||||||
|
->call('authenticate')
|
||||||
|
->assertRedirect(App\Support\AdminArea::home());
|
||||||
|
|
||||||
|
expect(Auth::guard('operator')->check())->toBeTrue()
|
||||||
|
->and(Auth::guard('web')->check())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a portal account at the console door', function () {
|
||||||
|
$user = User::factory()->create(['email' => 'kunde@clupilot.test', 'password' => 'kunden-passwort']);
|
||||||
|
|
||||||
|
Livewire::test(OperatorLogin::class)
|
||||||
|
->set('email', 'kunde@clupilot.test')
|
||||||
|
->set('password', 'kunden-passwort')
|
||||||
|
->call('authenticate')
|
||||||
|
->assertHasErrors('email');
|
||||||
|
|
||||||
|
expect(Auth::guard('operator')->check())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a disabled operator', function () {
|
||||||
|
$operator = Operator::factory()->role('Owner')->create([
|
||||||
|
'password' => 'richtig-langes-pw', 'disabled_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(OperatorLogin::class)
|
||||||
|
->set('email', $operator->email)
|
||||||
|
->set('password', 'richtig-langes-pw')
|
||||||
|
->call('authenticate')
|
||||||
|
->assertHasErrors('email');
|
||||||
|
|
||||||
|
expect(Auth::guard('operator')->check())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends an operator with confirmed two-factor to the challenge instead of the console', function () {
|
||||||
|
$operator = Operator::factory()->role('Owner')->create([
|
||||||
|
'password' => 'richtig-langes-pw',
|
||||||
|
'two_factor_secret' => encrypt('JBSWY3DPEHPK3PXP'),
|
||||||
|
'two_factor_confirmed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(OperatorLogin::class)
|
||||||
|
->set('email', $operator->email)
|
||||||
|
->set('password', 'richtig-langes-pw')
|
||||||
|
->call('authenticate')
|
||||||
|
->assertRedirect(route('admin.two-factor'));
|
||||||
|
|
||||||
|
// Not signed in yet — the challenge is not decoration.
|
||||||
|
expect(Auth::guard('operator')->check())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throttles repeated failures', function () {
|
||||||
|
$operator = Operator::factory()->role('Owner')->create(['password' => 'richtig-langes-pw']);
|
||||||
|
|
||||||
|
$component = Livewire::test(OperatorLogin::class)->set('email', $operator->email)->set('password', 'falsch');
|
||||||
|
|
||||||
|
foreach (range(1, 5) as $ignored) {
|
||||||
|
$component->call('authenticate');
|
||||||
|
}
|
||||||
|
|
||||||
|
$component->call('authenticate')->assertHasErrors('email');
|
||||||
|
expect(session()->has('login.id'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records when the operator last signed in', function () {
|
||||||
|
$operator = Operator::factory()->role('Owner')->create(['password' => 'richtig-langes-pw']);
|
||||||
|
|
||||||
|
Livewire::test(OperatorLogin::class)
|
||||||
|
->set('email', $operator->email)->set('password', 'richtig-langes-pw')
|
||||||
|
->call('authenticate');
|
||||||
|
|
||||||
|
expect($operator->fresh()->last_login_at)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Auth\OperatorTwoFactorChallenge;
|
||||||
|
use App\Models\Operator;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use PragmaRX\Google2FA\Google2FA;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The operator's two-factor step, exercised directly.
|
||||||
|
*
|
||||||
|
* OperatorLoginTest proves the password step hands off to this challenge
|
||||||
|
* without signing anyone in; these tests prove the challenge itself only
|
||||||
|
* completes the sign-in once the code actually checks out — the other half of
|
||||||
|
* "not signed in before the code is verified".
|
||||||
|
*/
|
||||||
|
function pendingOperator(array $attributes = []): Operator
|
||||||
|
{
|
||||||
|
$operator = Operator::factory()->role('Owner')->create(array_merge([
|
||||||
|
'password' => 'richtig-langes-pw',
|
||||||
|
'two_factor_secret' => encrypt('JBSWY3DPEHPK3PXP'),
|
||||||
|
'two_factor_recovery_codes' => encrypt(json_encode(['aaaa-1111', 'bbbb-2222'])),
|
||||||
|
'two_factor_confirmed_at' => now(),
|
||||||
|
], $attributes));
|
||||||
|
|
||||||
|
session(['operator.2fa.id' => $operator->id, 'operator.2fa.remember' => false]);
|
||||||
|
|
||||||
|
return $operator;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('is a dead end without a password step already taken', function () {
|
||||||
|
Livewire::test(OperatorTwoFactorChallenge::class)->assertForbidden();
|
||||||
|
|
||||||
|
expect(Auth::guard('operator')->check())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signs the operator in once the code actually checks out', function () {
|
||||||
|
$operator = pendingOperator();
|
||||||
|
$code = app(Google2FA::class)->getCurrentOtp(decrypt($operator->two_factor_secret));
|
||||||
|
|
||||||
|
Livewire::test(OperatorTwoFactorChallenge::class)
|
||||||
|
->set('code', $code)
|
||||||
|
->call('verify')
|
||||||
|
->assertRedirect(App\Support\AdminArea::home());
|
||||||
|
|
||||||
|
expect(Auth::guard('operator')->check())->toBeTrue()
|
||||||
|
->and(session()->has('operator.2fa.id'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a wrong code and leaves the operator signed out', function () {
|
||||||
|
$operator = pendingOperator();
|
||||||
|
|
||||||
|
Livewire::test(OperatorTwoFactorChallenge::class)
|
||||||
|
->set('code', '000000')
|
||||||
|
->call('verify')
|
||||||
|
->assertHasErrors('code');
|
||||||
|
|
||||||
|
expect(Auth::guard('operator')->check())->toBeFalse()
|
||||||
|
->and($operator->fresh()->last_login_at)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a recovery code exactly once', function () {
|
||||||
|
$operator = pendingOperator();
|
||||||
|
|
||||||
|
Livewire::test(OperatorTwoFactorChallenge::class)
|
||||||
|
->set('usingRecovery', true)
|
||||||
|
->set('recoveryCode', 'aaaa-1111')
|
||||||
|
->call('verify')
|
||||||
|
->assertRedirect(App\Support\AdminArea::home());
|
||||||
|
|
||||||
|
expect(Auth::guard('operator')->check())->toBeTrue();
|
||||||
|
|
||||||
|
$codes = json_decode(decrypt($operator->fresh()->two_factor_recovery_codes), true);
|
||||||
|
expect($codes)->not->toContain('aaaa-1111')
|
||||||
|
->and($codes)->toContain('bbbb-2222');
|
||||||
|
|
||||||
|
// Spent — signing back out and trying the same code again must fail.
|
||||||
|
Auth::guard('operator')->logout();
|
||||||
|
session(['operator.2fa.id' => $operator->id, 'operator.2fa.remember' => false]);
|
||||||
|
|
||||||
|
Livewire::test(OperatorTwoFactorChallenge::class)
|
||||||
|
->set('usingRecovery', true)
|
||||||
|
->set('recoveryCode', 'aaaa-1111')
|
||||||
|
->call('verify')
|
||||||
|
->assertHasErrors('code');
|
||||||
|
|
||||||
|
expect(Auth::guard('operator')->check())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throttles repeated wrong codes', function () {
|
||||||
|
pendingOperator();
|
||||||
|
|
||||||
|
$component = Livewire::test(OperatorTwoFactorChallenge::class)->set('code', '000000');
|
||||||
|
|
||||||
|
foreach (range(1, 5) as $ignored) {
|
||||||
|
$component->call('verify');
|
||||||
|
}
|
||||||
|
|
||||||
|
$component->call('verify')->assertHasErrors('code');
|
||||||
|
|
||||||
|
expect(Auth::guard('operator')->check())->toBeFalse();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue