clusev/app/Livewire/Auth/Login.php

104 lines
4.0 KiB
PHP

<?php
namespace App\Livewire\Auth;
use App\Models\AuditEvent;
use App\Models\User;
use App\Services\BruteforceGuard;
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
$ip = request()->ip();
app(BruteforceGuard::class)->record($ip, 'login');
AuditEvent::create([
'user_id' => null,
'actor' => 'system',
'action' => 'auth.login_failed',
'target' => $this->email,
'ip' => $ip,
]);
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'));
}
}