available(), 404); // Validate the label BEFORE the browser ceremony, so an invalid name never // leaves an orphaned credential on the authenticator. $this->validate(); return $webauthn->registrationOptions(Auth::user()); } public function register(array $attestation, WebauthnService $webauthn): void { // WebAuthn must be available (domain + HTTPS). No existing-factor guard — a key may // be the FIRST/only factor. abort_unless($webauthn->available(), 404); $this->validate(); $cred = $webauthn->verifyRegistration(Auth::user(), $attestation, trim($this->newName)); $this->reset('newName'); // First factor enrolled and no codes yet → generate + show the recovery modal. if (! Auth::user()->hasRecoveryCodes()) { Auth::user()->replaceRecoveryCodes(); // Server-set one-time flag so the opening modal reveals the fresh set once. session()->put('2fa.codes_fresh', now()->timestamp); // One-time download grant (a timestamp) — the download route requires + consumes // it within a short window, so the fresh set can be downloaded once and a later // direct hit 403s. session()->put('2fa.download_grant', now()->timestamp); $this->dispatch('openModal', component: 'modals.recovery-codes'); } 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', 'notify' => '', 'token' => ConfirmToken::issue( 'webauthnKeyRemoved', ['id' => $id], ), ], ); } #[On('webauthnKeyRemoved')] public function remove(string $confirmToken, WebauthnService $webauthn): void { abort_unless($webauthn->available(), 404); try { $payload = ConfirmToken::consume($confirmToken, 'webauthnKeyRemoved'); } catch (InvalidConfirmToken) { return; // forged / replayed / direct-bypass attempt — no-op } $id = $payload['params']['id']; $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')); Auth::user()->resetIfNoFactor(); } } public function render() { return view('livewire.settings.webauthn-keys', [ 'available' => app(WebauthnService::class)->available(), 'keys' => Auth::user()->webauthnCredentials()->latest()->get(), ]); } }