From 1fef9e61fecd5860d0634d77f1fca8436c8d9d58 Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 20 Jun 2026 15:24:09 +0200 Subject: [PATCH] refactor(auth): extract CompletesTwoFactorChallenge trait --- app/Livewire/Auth/TwoFactorChallenge.php | 95 +--------------- .../Concerns/CompletesTwoFactorChallenge.php | 104 ++++++++++++++++++ 2 files changed, 109 insertions(+), 90 deletions(-) create mode 100644 app/Livewire/Concerns/CompletesTwoFactorChallenge.php diff --git a/app/Livewire/Auth/TwoFactorChallenge.php b/app/Livewire/Auth/TwoFactorChallenge.php index c6feb8d..9764bf0 100644 --- a/app/Livewire/Auth/TwoFactorChallenge.php +++ b/app/Livewire/Auth/TwoFactorChallenge.php @@ -2,10 +2,9 @@ namespace App\Livewire\Auth; +use App\Livewire\Concerns\CompletesTwoFactorChallenge; use App\Models\User; use App\Services\WebauthnService; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\RateLimiter; use Illuminate\Validation\ValidationException; use Livewire\Attributes\Layout; use Livewire\Attributes\Validate; @@ -14,24 +13,11 @@ use Livewire\Component; #[Layout('layouts.auth')] class TwoFactorChallenge extends Component { + use CompletesTwoFactorChallenge; + #[Validate('required|string')] public string $code = ''; - private ?User $pendingUser = null; - - private bool $pendingResolved = false; - - /** The pending 2FA user, resolved once per request from the session. */ - private function pendingUser(): ?User - { - if (! $this->pendingResolved) { - $this->pendingUser = User::find(session('2fa.user')); - $this->pendingResolved = true; - } - - return $this->pendingUser; - } - public function mount() { if (! session()->has('2fa.user')) { @@ -39,55 +25,6 @@ class TwoFactorChallenge extends Component } } - /** 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(); @@ -112,23 +49,7 @@ class TwoFactorChallenge extends Component $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(); + return $this->completeLogin($user); } /** JSON request options for navigator.credentials.get — the JS posts the result to verifyWebauthn. */ @@ -154,13 +75,7 @@ class TwoFactorChallenge extends Component $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); + return $this->completeLogin($user); } public function render() diff --git a/app/Livewire/Concerns/CompletesTwoFactorChallenge.php b/app/Livewire/Concerns/CompletesTwoFactorChallenge.php new file mode 100644 index 0000000..b9af749 --- /dev/null +++ b/app/Livewire/Concerns/CompletesTwoFactorChallenge.php @@ -0,0 +1,104 @@ +pendingResolved) { + $this->pendingUser = User::find(session('2fa.user')); + $this->pendingResolved = true; + } + + return $this->pendingUser; + } + + /** Whether the PENDING user has TOTP — the authenticator code field renders only then. */ + public function getPendingHasTotpProperty(): bool + { + return (bool) $this->pendingUser()?->hasTotp(); + } + + /** 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(); + } + + /** + * 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) brute-force of the code is still capped. Both + * decay in minutes (never a permanent lockout; clusev:reset-admin stays open). + * + * @return array key => [maxAttempts, decaySeconds] + */ + protected function rateLimitBuckets(): array + { + $uid = (string) session('2fa.user'); + + return [ + 'two-factor:'.md5($uid.'|'.request()->ip()) => [5, 60], + 'two-factor-acct:'.md5($uid) => [20, 900], + ]; + } + + protected 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)]), + ]); + } + } + } + + protected function hitRateLimit(): void + { + foreach ($this->rateLimitBuckets() as $key => [, $decay]) { + RateLimiter::hit($key, $decay); + } + } + + protected function clearRateLimit(): void + { + foreach (array_keys($this->rateLimitBuckets()) as $key) { + RateLimiter::clear($key); + } + } + + /** Log the verified pending user in and finish the challenge (shared success tail). */ + protected function completeLogin(User $user) + { + $remember = (bool) session('2fa.remember'); + session()->forget(['2fa.user', '2fa.remember']); + + Auth::login($user, $remember); + session()->regenerate(); + + return $this->redirectIntended(route('dashboard'), navigate: true); + } +}