93 lines
3.6 KiB
PHP
93 lines
3.6 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Auth;
|
|
|
|
use App\Models\User;
|
|
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\Attributes\Validate;
|
|
use Livewire\Component;
|
|
|
|
#[Layout('layouts.auth')]
|
|
class Login extends Component
|
|
{
|
|
/**
|
|
* A fixed, valid cost-12 bcrypt hash. When the e-mail is unknown we still run one
|
|
* Hash::check against this so the failure branch costs the same bcrypt as a known
|
|
* account — closing the response-timing oracle that would otherwise reveal which
|
|
* addresses exist (same class as the password-reset constant-time fix).
|
|
*/
|
|
private const DUMMY_HASH = '$2y$12$6gKO7fsTd5ZoshlNsI7JpeuIU60K30r3K0mdPdIryNc2IQy8XwI.u';
|
|
|
|
#[Validate('required|email')]
|
|
public string $email = '';
|
|
|
|
#[Validate('required')]
|
|
public string $password = '';
|
|
|
|
public bool $remember = false;
|
|
|
|
public function authenticate()
|
|
{
|
|
$this->validate();
|
|
|
|
// Three auto-expiring counters, none of which can permanently lock the control plane
|
|
// (all decay in minutes; the host CLI `clusev:reset-admin` is always available):
|
|
// - pair (email+IP): the tight 5/min bucket (unchanged).
|
|
// - ip : caps one source hammering many accounts AND the bcrypt CPU it burns.
|
|
// - acct : an IP-independent backstop so a DISTRIBUTED (multi-IP) brute-force of the
|
|
// single admin account is still capped instead of scaling linearly with IPs.
|
|
$email = strtolower($this->email);
|
|
$pairKey = 'login:'.md5($email.'|'.request()->ip());
|
|
$ipKey = 'login-ip:'.md5(request()->ip());
|
|
$acctKey = 'login-acct:'.md5($email);
|
|
|
|
foreach ([[$pairKey, 5], [$ipKey, 20], [$acctKey, 30]] as [$k, $max]) {
|
|
if (RateLimiter::tooManyAttempts($k, $max)) {
|
|
throw ValidationException::withMessages([
|
|
'email' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($k)]),
|
|
]);
|
|
}
|
|
}
|
|
|
|
$user = User::where('email', $this->email)->first();
|
|
|
|
// Evaluate the bcrypt FIRST (never short-circuit it): an unknown e-mail must still spend
|
|
// one Hash::check against DUMMY_HASH, otherwise `! $user ||` would skip the hash and the
|
|
// faster no-account response would re-open the enumeration timing oracle.
|
|
$validPassword = Hash::check($this->password, $user?->password ?? self::DUMMY_HASH);
|
|
|
|
if (! $user || ! $validPassword) {
|
|
RateLimiter::hit($pairKey, 60); // 5 / minute
|
|
RateLimiter::hit($ipKey, 60); // 20 / minute
|
|
RateLimiter::hit($acctKey, 900); // 30 / 15 minutes
|
|
throw ValidationException::withMessages(['email' => __('auth.invalid_credentials')]);
|
|
}
|
|
|
|
// Only the tight pair bucket is cleared on success; the broader ip/acct counters
|
|
// decay on their own so one valid login can't reset a flood's budget.
|
|
RateLimiter::clear($pairKey);
|
|
|
|
// 2FA gate: hold the login until the TOTP code is verified.
|
|
if ($user->hasTwoFactorEnabled()) {
|
|
session()->put('2fa.user', $user->id);
|
|
session()->put('2fa.remember', $this->remember);
|
|
|
|
return $this->redirect(route('two-factor.challenge'), navigate: true);
|
|
}
|
|
|
|
Auth::login($user, $this->remember);
|
|
session()->regenerate();
|
|
|
|
return $this->redirectIntended(route('dashboard'), navigate: true);
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
return view('livewire.auth.login')->title(__('auth.title_login'));
|
|
}
|
|
}
|