clusev/app/Livewire/Auth/TwoFactorChallenge.php

140 lines
4.5 KiB
PHP

<?php
namespace App\Livewire\Auth;
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;
use Livewire\Component;
#[Layout('layouts.auth')]
class TwoFactorChallenge extends Component
{
#[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')) {
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();
}
public function verify()
{
$this->validate();
$key = 'two-factor:'.md5(session('2fa.user').'|'.request()->ip());
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
$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) {
RateLimiter::hit($key, 60);
throw ValidationException::withMessages(['code' => __('auth.invalid_code')]);
}
RateLimiter::clear($key);
$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)
{
$key = 'two-factor:'.md5(session('2fa.user').'|'.request()->ip());
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
$user = User::find(session('2fa.user'));
if (! $user || ! $user->hasTwoFactorEnabled() || ! $webauthn->available() || ! $webauthn->verifyAssertion($user, $assertion)) {
RateLimiter::hit($key, 60);
throw ValidationException::withMessages(['code' => __('auth.webauthn_failed')]);
}
RateLimiter::clear($key);
$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'));
}
}