diff --git a/app/Livewire/Auth/TwoFactorChallenge.php b/app/Livewire/Auth/TwoFactorChallenge.php index 96a25be..3ab0331 100644 --- a/app/Livewire/Auth/TwoFactorChallenge.php +++ b/app/Livewire/Auth/TwoFactorChallenge.php @@ -3,6 +3,7 @@ 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; @@ -63,6 +64,54 @@ class TwoFactorChallenge extends Component 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 = User::find(session('2fa.user')); + + 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')); diff --git a/lang/de/auth.php b/lang/de/auth.php index fdbe49e..53f6ec7 100644 --- a/lang/de/auth.php +++ b/lang/de/auth.php @@ -98,4 +98,21 @@ return [ 'reset_done' => 'Passwort geändert. Bitte neu anmelden.', 'reset_link_sent' => 'Falls die E-Mail existiert, wurde ein Reset-Link versendet.', 'reset_token_invalid' => 'Reset-Link ungültig oder abgelaufen.', + + // ── WebAuthn / Security-Keys ───────────────────────────────────────── + 'webauthn_login' => 'Mit Security-Key anmelden', + 'webauthn_failed' => 'Security-Key konnte nicht bestätigt werden.', + 'webauthn_title' => 'Security-Keys', + 'webauthn_subtitle' => 'Hardware-Schlüssel (z. B. YubiKey) als zweiten Faktor beim Login.', + 'webauthn_unavailable' => 'Security-Keys sind nur verfügbar, wenn das Panel unter einer Domain mit HTTPS läuft.', + 'webauthn_name' => 'Bezeichnung', + 'webauthn_name_placeholder' => 'z. B. YubiKey 5C', + 'webauthn_add' => 'Security-Key hinzufügen', + 'webauthn_added' => 'Security-Key hinzugefügt.', + 'webauthn_removed' => 'Security-Key entfernt.', + 'webauthn_none' => 'Noch kein Security-Key registriert.', + 'webauthn_added_on' => 'Hinzugefügt :date', + 'webauthn_last_used' => 'Zuletzt verwendet :date', + 'webauthn_never_used' => 'Noch nicht verwendet', + 'webauthn_remove_confirm' => 'Diesen Security-Key entfernen?', ]; diff --git a/lang/de/common.php b/lang/de/common.php index 615ee61..ae2a059 100644 --- a/lang/de/common.php +++ b/lang/de/common.php @@ -21,6 +21,7 @@ return [ 'open' => 'Öffnen', 'more' => 'mehr', 'apply' => 'Anwenden', + 'or' => 'oder', // Status / state 'online' => 'Online', diff --git a/lang/en/auth.php b/lang/en/auth.php index 0bbf85a..e01e9f8 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -98,4 +98,21 @@ return [ 'reset_done' => 'Password changed. Please sign in again.', 'reset_link_sent' => 'If the email exists, a reset link has been sent.', 'reset_token_invalid' => 'Reset link is invalid or expired.', + + // ── WebAuthn / security keys ───────────────────────────────────────── + 'webauthn_login' => 'Sign in with a security key', + 'webauthn_failed' => 'Could not verify the security key.', + 'webauthn_title' => 'Security keys', + 'webauthn_subtitle' => 'Hardware keys (e.g. YubiKey) as a second factor at login.', + 'webauthn_unavailable' => 'Security keys are only available when the panel runs under a domain with HTTPS.', + 'webauthn_name' => 'Label', + 'webauthn_name_placeholder' => 'e.g. YubiKey 5C', + 'webauthn_add' => 'Add security key', + 'webauthn_added' => 'Security key added.', + 'webauthn_removed' => 'Security key removed.', + 'webauthn_none' => 'No security key registered yet.', + 'webauthn_added_on' => 'Added :date', + 'webauthn_last_used' => 'Last used :date', + 'webauthn_never_used' => 'Not used yet', + 'webauthn_remove_confirm' => 'Remove this security key?', ]; diff --git a/lang/en/common.php b/lang/en/common.php index 0736d4f..4156052 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -21,6 +21,7 @@ return [ 'open' => 'Open', 'more' => 'more', 'apply' => 'Apply', + 'or' => 'or', // Status / state 'online' => 'Online', diff --git a/resources/views/livewire/auth/two-factor-challenge.blade.php b/resources/views/livewire/auth/two-factor-challenge.blade.php index 901347d..b64e731 100644 --- a/resources/views/livewire/auth/two-factor-challenge.blade.php +++ b/resources/views/livewire/auth/two-factor-challenge.blade.php @@ -27,6 +27,17 @@ + @if ($this->webauthnAvailable()) +
+ + {{ __('common.or') }} + +
+ + {{ __('auth.webauthn_login') }} + + @endif +
{{ __('auth.back_to_login') }} diff --git a/tests/Feature/TwoFactorWebauthnTest.php b/tests/Feature/TwoFactorWebauthnTest.php new file mode 100644 index 0000000..5945cee --- /dev/null +++ b/tests/Feature/TwoFactorWebauthnTest.php @@ -0,0 +1,49 @@ +create(['two_factor_secret' => 'x', 'two_factor_confirmed_at' => now()]); + session()->put('2fa.user', $user->id); + + return $user; + } + + public function test_verified_assertion_logs_in(): void + { + $user = $this->pending(); + $svc = $this->mock(WebauthnService::class); + $svc->shouldReceive('available')->andReturn(true); + $svc->shouldReceive('verifyAssertion')->andReturn(true); + + Livewire::test(TwoFactorChallenge::class)->call('verifyWebauthn', ['fake' => 'assertion']); + + $this->assertAuthenticatedAs($user); + } + + public function test_failed_assertion_does_not_log_in(): void + { + $this->pending(); + $svc = $this->mock(WebauthnService::class); + $svc->shouldReceive('available')->andReturn(true); + $svc->shouldReceive('verifyAssertion')->andReturn(false); + + Livewire::test(TwoFactorChallenge::class) + ->call('verifyWebauthn', ['fake' => 'assertion']) + ->assertHasErrors('code'); + + $this->assertGuest(); + } +}