docs(spec): WebAuthn/YubiKey 2nd-factor design (Phase 2)
Optional security-key 2nd factor via web-auth/webauthn-lib, gated on domain + HTTPS (rpId = active domain). TOTP + backup codes stay the required 2FA and the only recovery. Build now with unit/feature tests (mocked ceremony) + R12 gating; real YubiKey end-to-end deferred to a domain+HTTPS host. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/v1-foundation
parent
7faa5c7d00
commit
27ebc570f4
|
|
@ -0,0 +1,121 @@
|
|||
# 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` — build `PublicKeyCredentialCreationOptions`
|
||||
(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 with `AuthenticatorAttestationResponseValidator`
|
||||
(attestation type `none` accepted — for a 2nd factor we verify the credential, not provenance),
|
||||
store the resulting source as a `WebauthnCredential` named `$name`. Throws on mismatch.
|
||||
- `assertionOptions(User $user): array` — build `PublicKeyCredentialRequestOptions`
|
||||
(rpId=domain, `allowCredentials` = the user's credentials); persist `webauthn.login.challenge`.
|
||||
- `verifyAssertion(User $user, array $response): bool` — validate with
|
||||
`AuthenticatorAssertionResponseValidator` against the matching stored credential + session
|
||||
challenge; on success bump `sign_count` + `last_used_at` and 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)
|
||||
1. Operator clicks "Security-Key hinzufügen" → JS fetches `registrationOptions` (Livewire action
|
||||
returns the JSON options) → `navigator.credentials.create({publicKey})`.
|
||||
2. JS base64url-encodes the attestation response and posts it back (Livewire action) with a label.
|
||||
3. `verifyRegistration()` stores the credential. The key appears in the list (name, added date,
|
||||
last used) with a remove action (confirm modal, R5; audited `webauthn.register` /
|
||||
`webauthn.remove`).
|
||||
|
||||
### Login (TwoFactorChallenge, only when `available()` and the user has ≥1 credential)
|
||||
1. Alongside the existing code field, show "Mit Security-Key anmelden".
|
||||
2. JS fetches `assertionOptions` → `navigator.credentials.get({publicKey})` → posts the assertion.
|
||||
3. `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_count` regression 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 (mock `DeploymentService` +
|
||||
request scheme): true only for domain + secure.
|
||||
- `registrationOptions`/`assertionOptions` produce options with rpId = the domain and the right
|
||||
allow/exclude credential lists; challenge persisted in session.
|
||||
- Credential storage + `User::hasWebauthnCredentials` + remove.
|
||||
- Challenge integration: with `WebauthnService` **mocked** so `verifyAssertion` returns 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`).
|
||||
Loading…
Reference in New Issue