refactor(auth): extract CompletesTwoFactorChallenge trait
parent
f581529164
commit
1fef9e61fe
|
|
@ -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<string, array{0:int,1:int}> 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()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Concerns;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\WebauthnService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* Shared 2FA-challenge plumbing for the TOTP/key view and the backup-only view: the
|
||||
* pending-user resolver, the brute-force buckets (ONE shared counter set across both
|
||||
* views), and the login success tail. Centralising it guarantees both views hit the
|
||||
* same rate limit and complete login identically.
|
||||
*/
|
||||
trait CompletesTwoFactorChallenge
|
||||
{
|
||||
protected ?User $pendingUser = null;
|
||||
|
||||
protected bool $pendingResolved = false;
|
||||
|
||||
/** The pending 2FA user, resolved once per request from the session. */
|
||||
protected function pendingUser(): ?User
|
||||
{
|
||||
if (! $this->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<string, array{0:int,1:int}> 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue