From fe5c190eb0b681269c6e6c64541ce42749a9d90d Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 18:29:51 +0200 Subject: [PATCH] feat(webauthn): register/list/remove security keys in settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A "Security-Keys" panel in Settings โ†’ Security: add (name + navigator.credentials .create via resources/js/webauthn.js), list (name/added/last-used), remove (R5 confirm modal), all audited. Hidden with a hint when domain+HTTPS is absent. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Livewire/Settings/WebauthnKeys.php | 96 +++++++++++++++++++ resources/js/app.js | 1 + resources/js/webauthn.js | 82 ++++++++++++++++ .../views/livewire/settings/index.blade.php | 2 + .../livewire/settings/webauthn-keys.blade.php | 41 ++++++++ tests/Feature/WebauthnKeysTest.php | 46 +++++++++ 6 files changed, 268 insertions(+) create mode 100644 app/Livewire/Settings/WebauthnKeys.php create mode 100644 resources/js/webauthn.js create mode 100644 resources/views/livewire/settings/webauthn-keys.blade.php create mode 100644 tests/Feature/WebauthnKeysTest.php diff --git a/app/Livewire/Settings/WebauthnKeys.php b/app/Livewire/Settings/WebauthnKeys.php new file mode 100644 index 0000000..b622e26 --- /dev/null +++ b/app/Livewire/Settings/WebauthnKeys.php @@ -0,0 +1,96 @@ +available(), 404); + + return $webauthn->registrationOptions(Auth::user()); + } + + public function register(array $attestation, WebauthnService $webauthn): void + { + abort_unless($webauthn->available(), 404); + $this->validate(); + + $cred = $webauthn->verifyRegistration(Auth::user(), $attestation, trim($this->newName)); + $this->reset('newName'); + + AuditEvent::create([ + 'user_id' => Auth::id(), + 'actor' => Auth::user()->name ?? 'system', + 'action' => 'webauthn.register', + 'target' => $cred->name, + 'ip' => request()->ip(), + ]); + + $this->dispatch('notify', message: __('auth.webauthn_added')); + } + + /** Destructive (R5): open the confirm modal, which re-dispatches `webauthnKeyRemoved`. */ + public function confirmRemove(int $id): void + { + $cred = Auth::user()->webauthnCredentials()->whereKey($id)->first(); + if (! $cred) { + return; + } + + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => __('auth.webauthn_title'), + 'body' => __('auth.webauthn_remove_confirm'), + 'confirmLabel' => __('common.remove'), + 'danger' => true, + 'icon' => 'trash', + 'event' => 'webauthnKeyRemoved', + 'params' => ['id' => $id], + 'notify' => '', + ], + ); + } + + #[On('webauthnKeyRemoved')] + public function remove(int $id, WebauthnService $webauthn): void + { + abort_unless($webauthn->available(), 404); + + $cred = Auth::user()->webauthnCredentials()->whereKey($id)->first(); + if ($cred) { + $name = $cred->name; + $cred->delete(); + + AuditEvent::create([ + 'user_id' => Auth::id(), + 'actor' => Auth::user()->name ?? 'system', + 'action' => 'webauthn.remove', + 'target' => $name, + 'ip' => request()->ip(), + ]); + + $this->dispatch('notify', message: __('auth.webauthn_removed')); + } + } + + public function render() + { + return view('livewire.settings.webauthn-keys', [ + 'available' => app(WebauthnService::class)->available(), + 'keys' => Auth::user()->webauthnCredentials()->latest()->get(), + ]); + } +} diff --git a/resources/js/app.js b/resources/js/app.js index 546737d..dade033 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,5 +1,6 @@ import Echo from 'laravel-echo'; import Pusher from 'pusher-js'; +import './webauthn'; // exposes window.clusevWebauthn (register/login ceremonies) window.Pusher = Pusher; diff --git a/resources/js/webauthn.js b/resources/js/webauthn.js new file mode 100644 index 0000000..a8090be --- /dev/null +++ b/resources/js/webauthn.js @@ -0,0 +1,82 @@ +// WebAuthn ceremony bridge: converts the JSON options from the server into the +// ArrayBuffers navigator.credentials needs, runs the create/get ceremony, and posts +// the (base64url-encoded) result back to the Livewire component. +// Exposed as window.clusevWebauthn for the Settings + 2FA-challenge views. + +const b64uToBuf = (s) => { + const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4)); + const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/') + pad); + const buf = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); + return buf.buffer; +}; + +const bufToB64u = (buf) => { + let bin = ''; + const bytes = new Uint8Array(buf); + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +// Map server creation options (base64url strings) โ†’ PublicKeyCredentialCreationOptions. +const toCreateOptions = (o) => { + const pk = { ...o }; + pk.challenge = b64uToBuf(o.challenge); + pk.user = { ...o.user, id: b64uToBuf(o.user.id) }; + pk.excludeCredentials = (o.excludeCredentials || []).map((c) => ({ ...c, id: b64uToBuf(c.id) })); + return pk; +}; + +const toRequestOptions = (o) => { + const pk = { ...o }; + pk.challenge = b64uToBuf(o.challenge); + pk.allowCredentials = (o.allowCredentials || []).map((c) => ({ ...c, id: b64uToBuf(c.id) })); + return pk; +}; + +const encodeAttestation = (cred) => ({ + id: cred.id, + type: cred.type, + rawId: bufToB64u(cred.rawId), + response: { + clientDataJSON: bufToB64u(cred.response.clientDataJSON), + attestationObject: bufToB64u(cred.response.attestationObject), + }, + clientExtensionResults: cred.getClientExtensionResults?.() ?? {}, +}); + +const encodeAssertion = (cred) => ({ + id: cred.id, + type: cred.type, + rawId: bufToB64u(cred.rawId), + response: { + clientDataJSON: bufToB64u(cred.response.clientDataJSON), + authenticatorData: bufToB64u(cred.response.authenticatorData), + signature: bufToB64u(cred.response.signature), + userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null, + }, + clientExtensionResults: cred.getClientExtensionResults?.() ?? {}, +}); + +window.clusevWebauthn = { + async register($wire) { + try { + const options = await $wire.options(); + const cred = await navigator.credentials.create({ publicKey: toCreateOptions(options) }); + await $wire.register(encodeAttestation(cred)); + } catch (e) { + // user cancelled or the authenticator failed โ€” surface nothing noisy + console.warn('WebAuthn registration aborted', e); + } + }, + + async login($wire) { + try { + const options = await $wire.assertionOptions(); + const cred = await navigator.credentials.get({ publicKey: toRequestOptions(options) }); + await $wire.verifyWebauthn(encodeAssertion(cred)); + } catch (e) { + console.warn('WebAuthn login aborted', e); + } + }, +}; diff --git a/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php index ba0f613..d765d1c 100644 --- a/resources/views/livewire/settings/index.blade.php +++ b/resources/views/livewire/settings/index.blade.php @@ -117,6 +117,8 @@ @endif + + @endif diff --git a/resources/views/livewire/settings/webauthn-keys.blade.php b/resources/views/livewire/settings/webauthn-keys.blade.php new file mode 100644 index 0000000..49a8789 --- /dev/null +++ b/resources/views/livewire/settings/webauthn-keys.blade.php @@ -0,0 +1,41 @@ + + @if (! $available) +
+ +

{{ __('auth.webauthn_unavailable') }}

+
+ @else +
+
+ @forelse ($keys as $key) +
+ + + +
+

{{ $key->name }}

+

+ {{ __('auth.webauthn_added_on', ['date' => $key->created_at->diffForHumans()]) }} + ยท {{ $key->last_used_at ? __('auth.webauthn_last_used', ['date' => $key->last_used_at->diffForHumans()]) : __('auth.webauthn_never_used') }} +

+
+ + {{ __('common.remove') }} + +
+ @empty +

{{ __('auth.webauthn_none') }}

+ @endforelse +
+ +
+ + + {{ __('auth.webauthn_add') }} + +
+ @error('newName')

{{ $message }}

@enderror +
+ @endif +
diff --git a/tests/Feature/WebauthnKeysTest.php b/tests/Feature/WebauthnKeysTest.php new file mode 100644 index 0000000..7c75798 --- /dev/null +++ b/tests/Feature/WebauthnKeysTest.php @@ -0,0 +1,46 @@ +create(); + $this->actingAs($user); + $svc = $this->mock(WebauthnService::class); + $svc->shouldReceive('available')->andReturn(true); + $svc->shouldReceive('verifyRegistration')->andReturnUsing(fn ($u, $r, $name) => WebauthnCredential::create([ + 'user_id' => $u->id, 'name' => $name, 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0, + ])); + + Livewire::test(WebauthnKeys::class) + ->set('newName', 'YubiKey 5C') + ->call('register', ['fake' => 'attestation']) + ->assertHasNoErrors(); + + $this->assertDatabaseHas('webauthn_credentials', ['user_id' => $user->id, 'name' => 'YubiKey 5C']); + } + + public function test_remove_deletes_credential(): void + { + $user = User::factory()->create(); + $this->actingAs($user); + $cred = WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); + $this->mock(WebauthnService::class)->shouldReceive('available')->andReturn(true); + + Livewire::test(WebauthnKeys::class)->call('remove', $cred->id); + + $this->assertDatabaseMissing('webauthn_credentials', ['id' => $cred->id]); + } +}