feat(webauthn): register/list/remove security keys in settings
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) <noreply@anthropic.com>feat/v1-foundation
parent
7afa50d253
commit
fe5c190eb0
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Services\WebauthnService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
class WebauthnKeys extends Component
|
||||
{
|
||||
#[Validate('required|string|max:60')]
|
||||
public string $newName = '';
|
||||
|
||||
/** JSON creation options for navigator.credentials.create (the JS posts the result to register). */
|
||||
public function options(WebauthnService $webauthn): array
|
||||
{
|
||||
abort_unless($webauthn->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -117,6 +117,8 @@
|
|||
@endif
|
||||
</div>
|
||||
</x-panel>
|
||||
|
||||
<livewire:settings.webauthn-keys />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
<x-panel :title="__('auth.webauthn_title')" :subtitle="__('auth.webauthn_subtitle')">
|
||||
@if (! $available)
|
||||
<div class="flex items-start gap-2.5 rounded-md border border-line bg-inset px-4 py-3">
|
||||
<x-icon name="lock" class="mt-0.5 h-4 w-4 shrink-0 text-ink-4" />
|
||||
<p class="text-sm text-ink-3">{{ __('auth.webauthn_unavailable') }}</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-4">
|
||||
<div class="divide-y divide-line">
|
||||
@forelse ($keys as $key)
|
||||
<div class="flex items-center gap-3 py-3">
|
||||
<span class="grid h-9 w-9 shrink-0 place-items-center rounded-md border border-line bg-inset text-ink-3">
|
||||
<x-icon name="shield" class="h-4 w-4" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm text-ink">{{ $key->name }}</p>
|
||||
<p class="truncate font-mono text-[11px] text-ink-3">
|
||||
{{ __('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') }}
|
||||
</p>
|
||||
</div>
|
||||
<x-btn variant="danger-soft" size="sm" class="shrink-0" wire:click="confirmRemove({{ $key->id }})">
|
||||
<x-icon name="trash" class="h-3.5 w-3.5" /> {{ __('common.remove') }}
|
||||
</x-btn>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-3 font-mono text-[11px] text-ink-4">{{ __('auth.webauthn_none') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:flex-row" x-data>
|
||||
<input wire:model="newName" type="text" maxlength="60" placeholder="{{ __('auth.webauthn_name_placeholder') }}"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
<x-btn variant="accent" class="shrink-0" x-on:click="window.clusevWebauthn?.register($wire)">
|
||||
<x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('auth.webauthn_add') }}
|
||||
</x-btn>
|
||||
</div>
|
||||
@error('newName') <p class="font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
@endif
|
||||
</x-panel>
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Settings\WebauthnKeys;
|
||||
use App\Models\User;
|
||||
use App\Models\WebauthnCredential;
|
||||
use App\Services\WebauthnService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WebauthnKeysTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_register_stores_credential(): void
|
||||
{
|
||||
$user = User::factory()->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]);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue