104 lines
3.7 KiB
PHP
104 lines
3.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. */
|
|
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');
|
|
}
|
|
}
|