feat(webauthn): use a security key at the login challenge

When the panel runs on a domain+HTTPS and the user has a registered key, the 2FA
challenge offers "Mit Security-Key anmelden" alongside the TOTP/backup-code field.
A verified assertion completes login exactly like a TOTP success; rate-limit +
session gate reused. TOTP and backup codes remain fully usable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 18:26:58 +02:00
parent 0cb9b073ce
commit 7afa50d253
7 changed files with 145 additions and 0 deletions

View File

@ -3,6 +3,7 @@
namespace App\Livewire\Auth; namespace App\Livewire\Auth;
use App\Models\User; use App\Models\User;
use App\Services\WebauthnService;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -63,6 +64,54 @@ class TwoFactorChallenge extends Component
return $this->redirectIntended(route('dashboard'), navigate: true); 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() public function render()
{ {
return view('livewire.auth.two-factor-challenge')->title(__('auth.title_challenge')); return view('livewire.auth.two-factor-challenge')->title(__('auth.title_challenge'));

View File

@ -98,4 +98,21 @@ return [
'reset_done' => 'Passwort geändert. Bitte neu anmelden.', 'reset_done' => 'Passwort geändert. Bitte neu anmelden.',
'reset_link_sent' => 'Falls die E-Mail existiert, wurde ein Reset-Link versendet.', 'reset_link_sent' => 'Falls die E-Mail existiert, wurde ein Reset-Link versendet.',
'reset_token_invalid' => 'Reset-Link ungültig oder abgelaufen.', '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?',
]; ];

View File

@ -21,6 +21,7 @@ return [
'open' => 'Öffnen', 'open' => 'Öffnen',
'more' => 'mehr', 'more' => 'mehr',
'apply' => 'Anwenden', 'apply' => 'Anwenden',
'or' => 'oder',
// Status / state // Status / state
'online' => 'Online', 'online' => 'Online',

View File

@ -98,4 +98,21 @@ return [
'reset_done' => 'Password changed. Please sign in again.', 'reset_done' => 'Password changed. Please sign in again.',
'reset_link_sent' => 'If the email exists, a reset link has been sent.', 'reset_link_sent' => 'If the email exists, a reset link has been sent.',
'reset_token_invalid' => 'Reset link is invalid or expired.', '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?',
]; ];

View File

@ -21,6 +21,7 @@ return [
'open' => 'Open', 'open' => 'Open',
'more' => 'more', 'more' => 'more',
'apply' => 'Apply', 'apply' => 'Apply',
'or' => 'or',
// Status / state // Status / state
'online' => 'Online', 'online' => 'Online',

View File

@ -27,6 +27,17 @@
</x-btn> </x-btn>
</form> </form>
@if ($this->webauthnAvailable())
<div class="flex items-center gap-3">
<span class="h-px flex-1 bg-line"></span>
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('common.or') }}</span>
<span class="h-px flex-1 bg-line"></span>
</div>
<x-btn variant="secondary" size="lg" class="w-full" x-data x-on:click="window.clusevWebauthn?.login($wire)">
<x-icon name="shield" class="h-4 w-4" /> {{ __('auth.webauthn_login') }}
</x-btn>
@endif
<div class="flex justify-center"> <div class="flex justify-center">
<a href="{{ route('login') }}" wire:navigate class="inline-flex items-center gap-1.5 font-mono text-[11px] text-ink-4 transition-colors hover:text-ink-2"> <a href="{{ route('login') }}" wire:navigate class="inline-flex items-center gap-1.5 font-mono text-[11px] text-ink-4 transition-colors hover:text-ink-2">
<x-icon name="chevron-left" class="h-3.5 w-3.5" /> {{ __('auth.back_to_login') }} <x-icon name="chevron-left" class="h-3.5 w-3.5" /> {{ __('auth.back_to_login') }}

View File

@ -0,0 +1,49 @@
<?php
namespace Tests\Feature;
use App\Livewire\Auth\TwoFactorChallenge;
use App\Models\User;
use App\Services\WebauthnService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class TwoFactorWebauthnTest extends TestCase
{
use RefreshDatabase;
private function pending(): User
{
$user = User::factory()->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();
}
}