7.1 KiB
WebAuthn / YubiKey as a Login 2nd Factor — Design (Phase 2)
Date: 2026-06-14 · Branch: feat/v1-foundation · Status: approved
Adds WebAuthn security keys (YubiKey / platform authenticators) as an optional second factor at login, on top of the existing TOTP + backup-code recovery (v0.5.0). It is gated on a domain + HTTPS: WebAuthn requires a secure context and a domain-based Relying-Party ID — a bare IP literal is not a valid rpId, so the feature is hidden (with a hint) until the panel runs under a domain with a valid certificate. TOTP + backup codes remain the required 2FA and the only recovery; a security key never weakens recovery.
Auth is hand-rolled (not Fortify): Livewire Auth\{Login,TwoFactorChallenge,...}, TOTP via
pragmarx/google2fa. Domain/scheme state comes from DeploymentService::domain() (active
domain, null on bare-IP) and Request::isSecure() (real scheme behind Caddy/TrustProxies).
Library
web-auth/webauthn-lib ^5.3 — framework-agnostic, actively maintained, resolves cleanly on
Laravel 13.8 / PHP 8.3 (verified by composer require --dry-run). Chosen over laragear/webauthn
(abandoned) and laravel/passkeys (oriented to passwordless passkey login, not a step-up 2nd
factor). It provides the ceremony primitives; we wire them into the existing custom auth.
Gating — WebauthnService::available()
available(): bool = app(DeploymentService::class)->domain() !== null && request()->isSecure().
The rpId = the active domain; rpName = "Clusev". When available() is false:
- Settings shows a muted hint ("Security-Keys sind nur unter einer Domain mit HTTPS verfügbar") and no register button.
- The login challenge shows no "Security-Key" option.
- All WebAuthn routes/actions
abort(404)(defense in depth — never run a ceremony with an IP rpId).
Data model
Migration create_webauthn_credentials_table:
id,user_id(fk, cascade),name(operator label),credential_id(binary, unique),public_key(text),aaguid(nullable),transports(json nullable),sign_count(unsigned big int, default 0),last_used_at(nullable), timestamps.
WebauthnCredential model (user_id, name, credential_id, public_key, aaguid,
transports, sign_count, last_used_at); User hasMany(WebauthnCredential) +
hasWebauthnCredentials(): bool.
The library's PublicKeyCredentialSource is mapped to/from this row (credential_id, public key,
sign count, transports). A small mapper lives in WebauthnService.
WebauthnService (the one place that touches the lib)
available(): bool— the gate above.registrationOptions(User $user): array— buildPublicKeyCredentialCreationOptions(rpId=domain, user handle = stable per-user id,excludeCredentials= the user's existing credentials,residentKey=discouraged,userVerification=preferred), serialize to a JSON-ready array via the lib's Webauthn serializer; persist the challenge in the session (webauthn.register.challenge).verifyRegistration(User $user, array $response, string $name): WebauthnCredential— validate the attestation against the session challenge withAuthenticatorAttestationResponseValidator(attestation typenoneaccepted — for a 2nd factor we verify the credential, not provenance), store the resulting source as aWebauthnCredentialnamed$name. Throws on mismatch.assertionOptions(User $user): array— buildPublicKeyCredentialRequestOptions(rpId=domain,allowCredentials= the user's credentials); persistwebauthn.login.challenge.verifyAssertion(User $user, array $response): bool— validate withAuthenticatorAssertionResponseValidatoragainst the matching stored credential + session challenge; on success bumpsign_count+last_used_atand return true; false otherwise.
Serialization uses the lib's WebauthnSerializerFactory (symfony/serializer) for options → JSON
and incoming JSON → response objects.
Flows
Registration (Settings → Security, only when available() and TOTP already enrolled)
- Operator clicks "Security-Key hinzufügen" → JS fetches
registrationOptions(Livewire action returns the JSON options) →navigator.credentials.create({publicKey}). - JS base64url-encodes the attestation response and posts it back (Livewire action) with a label.
verifyRegistration()stores the credential. The key appears in the list (name, added date, last used) with a remove action (confirm modal, R5; auditedwebauthn.register/webauthn.remove).
Login (TwoFactorChallenge, only when available() and the user has ≥1 credential)
- Alongside the existing code field, show "Mit Security-Key anmelden".
- JS fetches
assertionOptions→navigator.credentials.get({publicKey})→ posts the assertion. verifyAssertion()true → complete login exactly like a TOTP success (clear rate-limit,Auth::login($user, $remember), regenerate session, redirect intended). TOTP code and backup codes remain fully usable in parallel.
The 2fa.user/2fa.remember session gate and the 5/min rate-limit are reused unchanged.
JS
A small resources/js/webauthn.js module: base64url encode/decode helpers + register(options)
and authenticate(options) wrappers around navigator.credentials. Wired to the Livewire
components via dispatched browser events / $wire calls. No new build system; bundled by Vite.
Security
- rpId strictly = the active domain (never an IP); ceremonies refuse to run otherwise (the gate).
- Challenges are single-use, stored server-side in the session, compared by the lib.
sign_countregression check (the lib flags a cloned authenticator).- Registration requires an authenticated, already-2FA-enrolled session; removal + registration are audited. Removing the last key never removes TOTP/backup-code 2FA (recovery preserved).
- User verification
preferred(PIN/biometric when the authenticator supports it).
Testing
- Unit/feature (now):
WebauthnService::available()across domain×scheme combos (mockDeploymentService+ request scheme): true only for domain + secure.registrationOptions/assertionOptionsproduce options with rpId = the domain and the right allow/exclude credential lists; challenge persisted in session.- Credential storage +
User::hasWebauthnCredentials+ remove. - Challenge integration: with
WebauthnServicemocked soverifyAssertionreturns true, a posted assertion completes login (assertAuthenticatedAs); when false, it does not. - Routes/actions abort when
available()is false (bare-IP).
- R12 (now, bare-IP): Settings + challenge correctly hide the WebAuthn option and show the hint; no console errors.
- Deferred to a domain+HTTPS host: real YubiKey register + login end-to-end (the cryptographic ceremony cannot run with an IP rpId). Documented in the handoff as the remaining manual check.
Out of scope
- Passwordless / passkey first-factor login (this is a 2nd factor only).
- Changing onboarding (TOTP + backup codes stay required) or the recovery flows.
- Attestation-provenance / MDS validation (not needed for a 2nd factor; attestation
none).