clusev/app/Livewire/Settings/WebauthnKeys.php

126 lines
4.4 KiB
PHP

<?php
namespace App\Livewire\Settings;
use App\Models\AuditEvent;
use App\Services\WebauthnService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
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);
// 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', true);
// 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(),
]);
}
}