CluPilotCloud/app/Livewire/Auth/OperatorLogin.php

128 lines
4.7 KiB
PHP

<?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.
*
* Re-checks isActive()/isOperator() here, not only at the password step:
* an operator can be disabled, or lose every console role, in the window
* between entering a password and completing a PENDING two-factor
* challenge — an owner revoking access while someone is mid-login is the
* realistic case, not a contrived one, and the challenge on its own only
* ever confirmed the row still existed. One check, in the one place both
* entry points already share, rather than a second copy in the challenge
* that the next change forgets to keep in sync.
*
* @return bool False when the operator no longer qualifies: the login is
* refused, not completed — neither the guard nor the
* session is touched, and the caller decides how to say so.
*/
public static function completeLogin(Operator $operator, bool $remember): bool
{
if (! $operator->isActive() || ! $operator->isOperator()) {
return false;
}
Auth::guard('operator')->login($operator, $remember);
session()->regenerate();
$operator->forceFill(['last_login_at' => now()])->save();
return true;
}
private function completeLoginAndRedirect(Operator $operator, bool $remember): void
{
if (! self::completeLogin($operator, $remember)) {
throw ValidationException::withMessages(['email' => __('auth.failed')]);
}
$this->redirect(AdminArea::home(), navigate: false);
}
public function render()
{
return view('livewire.auth.operator-login');
}
}