pendingResolved) { $this->pendingUser = User::find(session('2fa.user')); $this->pendingResolved = true; } return $this->pendingUser; } public function mount() { if (! session()->has('2fa.user')) { return $this->redirect(route('login'), navigate: true); } } /** Whether the PENDING user has TOTP — the authenticator code field renders only then. */ public function getPendingHasTotpProperty(): bool { return (bool) $this->pendingUser()?->hasTotp(); } /** * Two auto-expiring 2FA brute-force buckets, keyed on the SERVER-side pending user id * (not client-controlled) — a tight per-(user+IP) one plus an IP-independent per-user * backstop, so a distributed (multi-IP) attempt to brute-force the 6-digit TOTP is still * capped. Both decay in minutes (never a permanent lockout; clusev:reset-admin stays open). * * @return array key => [maxAttempts, decaySeconds] */ private function rateLimitBuckets(): array { $uid = (string) session('2fa.user'); return [ 'two-factor:'.md5($uid.'|'.request()->ip()) => [5, 60], 'two-factor-acct:'.md5($uid) => [20, 900], ]; } private function assertNotRateLimited(): void { foreach ($this->rateLimitBuckets() as $key => [$max]) { if (RateLimiter::tooManyAttempts($key, $max)) { throw ValidationException::withMessages([ 'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]), ]); } } } private function hitRateLimit(): void { foreach ($this->rateLimitBuckets() as $key => [, $decay]) { RateLimiter::hit($key, $decay); } } private function clearRateLimit(): void { foreach (array_keys($this->rateLimitBuckets()) as $key) { RateLimiter::clear($key); } } public function verify() { $this->validate(); $this->assertNotRateLimited(); $user = User::find(session('2fa.user')); if (! $user || ! $user->hasTwoFactorEnabled()) { session()->forget(['2fa.user', '2fa.remember']); throw ValidationException::withMessages(['code' => __('auth.session_expired')]); } // Accept the TOTP code only when the pending user actually has TOTP (otherwise a null // secret would break verifyKey); always allow a one-time backup (recovery) code. $valid = $user->verifyTotp($this->code) || $user->useRecoveryCode($this->code); if (! $valid) { $this->hitRateLimit(); throw ValidationException::withMessages(['code' => __('auth.invalid_code')]); } $this->clearRateLimit(); $remember = (bool) session('2fa.remember'); session()->forget(['2fa.user', '2fa.remember']); Auth::login($user, $remember); session()->regenerate(); return $this->redirectIntended(route('dashboard'), navigate: true); } /** Whether to offer the security-key option for the pending user (domain+HTTPS + has a key). */ public function webauthnAvailable(): bool { $user = $this->pendingUser(); return $user !== null && app(WebauthnService::class)->available() && $user->hasWebauthnCredentials(); } /** JSON request options for navigator.credentials.get — the JS posts the result to verifyWebauthn. */ public function assertionOptions(WebauthnService $webauthn): array { $user = User::find(session('2fa.user')); abort_unless($user && $webauthn->available() && $user->hasWebauthnCredentials(), 404); return $webauthn->assertionOptions($user); } /** Complete the login with a verified security-key assertion (alternative to the TOTP/backup code). */ public function verifyWebauthn(array $assertion, WebauthnService $webauthn) { $this->assertNotRateLimited(); $user = User::find(session('2fa.user')); if (! $user || ! $user->hasTwoFactorEnabled() || ! $webauthn->available() || ! $webauthn->verifyAssertion($user, $assertion)) { $this->hitRateLimit(); throw ValidationException::withMessages(['code' => __('auth.webauthn_failed')]); } $this->clearRateLimit(); $remember = (bool) session('2fa.remember'); session()->forget(['2fa.user', '2fa.remember']); Auth::login($user, $remember); session()->regenerate(); return $this->redirectIntended(route('dashboard'), navigate: true); } public function render() { return view('livewire.auth.two-factor-challenge')->title(__('auth.title_challenge')); } }