Give the console a front door of its own

feat/operator-identity
nexxo 2026-07-28 11:45:22 +02:00
parent 513cadc5ce
commit 3f318c3f6a
13 changed files with 532 additions and 6 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

15
routes/admin-guest.php Normal file
View File

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

View File

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

View File

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

View File

@ -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();
});

View File

@ -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();
});