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
parent
0cb9b073ce
commit
7afa50d253
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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?',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ return [
|
|||
'open' => 'Öffnen',
|
||||
'more' => 'mehr',
|
||||
'apply' => 'Anwenden',
|
||||
'or' => 'oder',
|
||||
|
||||
// Status / state
|
||||
'online' => 'Online',
|
||||
|
|
|
|||
|
|
@ -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?',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ return [
|
|||
'open' => 'Open',
|
||||
'more' => 'more',
|
||||
'apply' => 'Apply',
|
||||
'or' => 'or',
|
||||
|
||||
// Status / state
|
||||
'online' => 'Online',
|
||||
|
|
|
|||
|
|
@ -27,6 +27,17 @@
|
|||
</x-btn>
|
||||
</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">
|
||||
<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') }}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue