docs(spec): optional, pluggable 2FA (TOTP and/or YubiKey) design
2FA becomes optional + pluggable: TOTP, a security key, or both, fully deactivatable, recommended not forced. hasTwoFactorEnabled = either factor; onboarding drops the forced-2FA redirect; recovery codes become a modal (drop the dedicated page/route); challenge adapts to whichever factor(s) exist; last-factor removal clears codes. WebAuthn E2E stays domain-deferred. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/v1-foundation
parent
3bbd6bf096
commit
bdec68f661
|
|
@ -0,0 +1,123 @@
|
|||
# Optional, Pluggable 2FA (TOTP and/or YubiKey) — Design
|
||||
|
||||
**Date:** 2026-06-14 · **Branch:** `feat/v1-foundation` · **Status:** approved
|
||||
|
||||
Reworks 2FA from **forced TOTP (+ optional key)** to **optional, pluggable**: the operator decides
|
||||
whether to secure the account, choosing **TOTP, a security key (YubiKey/WebAuthn), or both** — and
|
||||
can turn 2FA off entirely. 2FA is *recommended*, not enforced. Builds on v0.5.0 (backup codes,
|
||||
forgot-password) and v0.6.0 (WebAuthn). Supersedes: the forced-2FA onboarding, and the dedicated
|
||||
recovery-codes **page** (now a modal).
|
||||
|
||||
Constraint that shapes everything: **WebAuthn needs domain + HTTPS** (rpId = domain), so on a
|
||||
fresh bare-IP install only TOTP is available; a key becomes an option once a domain is configured.
|
||||
Password rotation stays forced (`must_change_password`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Factor semantics (`User`)
|
||||
|
||||
- `hasTotp(): bool` = `two_factor_secret` + `two_factor_confirmed_at` set (the old
|
||||
`hasTwoFactorEnabled`).
|
||||
- `hasWebauthnCredentials(): bool` (exists) — already present.
|
||||
- **`hasTwoFactorEnabled(): bool` = `hasTotp() || hasWebauthnCredentials()`** — 2FA satisfied by
|
||||
**either** factor.
|
||||
- **`securityOnboarded(): bool` = `! must_change_password`** — drop the 2FA requirement (2FA is now
|
||||
optional). Used by the broadcast channel gate.
|
||||
|
||||
Update every caller (already enumerated): `EnsureSecurityOnboarded`, `Login`, `TwoFactorChallenge`,
|
||||
`WebauthnKeys`, `ForgotPassword`, `Settings\Index`, `routes/channels.php`.
|
||||
|
||||
## 2. Onboarding — 2FA no longer forced
|
||||
|
||||
`EnsureSecurityOnboarded`: keep the `must_change_password` → `password.change` redirect; **remove**
|
||||
the `! hasTwoFactorEnabled()` → `two-factor.setup` redirect. After the password change,
|
||||
`PasswordChange::update()` redirects to the **dashboard** (not `two-factor.setup`). 2FA is offered
|
||||
in Settings with a "empfohlen" hint.
|
||||
|
||||
`routes/channels.php`: `metrics` channel = `fn (User $u) => $u->securityOnboarded()` still holds,
|
||||
but `securityOnboarded()` now means "password rotated" — metrics stream to a password-rotated
|
||||
session regardless of 2FA (which is optional).
|
||||
|
||||
## 3. Recovery (backup) codes — universal, and a MODAL (no dedicated page)
|
||||
|
||||
Backup codes remain the single recovery, but their UI becomes a **wire-elements/modal**:
|
||||
- **New `Modals\RecoveryCodes`** (`LivewireUI\Modal\ModalComponent`): shows the current codes once,
|
||||
a **Download** action (`two-factor.recovery.download` endpoint stays — it's not a view), and a
|
||||
**Regenerate**. Opened via `$dispatch('openModal', { component: 'modals.recovery-codes' })`.
|
||||
- **Remove** `Auth\RecoveryCodes` (full-page component), its view
|
||||
`livewire/auth/recovery-codes.blade.php`, and the **`two-factor.recovery` route**. Keep the
|
||||
download route.
|
||||
- **Modal host:** the wire-elements/modal host lives in the **app layout** (not the auth layout).
|
||||
Since 2FA is now a voluntary Settings action (not pre-onboarding), `TwoFactorSetup` moves to the
|
||||
**app layout** so it can open the modal; alternatively the TOTP enroll becomes a Settings sub-flow.
|
||||
(Decided in the plan; either way the recovery modal opens within the app shell.)
|
||||
- **Generation:** when the **first** factor is enrolled and no codes exist yet, generate them
|
||||
(`replaceRecoveryCodes()`) and open the modal (no full-page redirect — the modal renders over the
|
||||
current app-shell page; its "Gespeichert" action returns to Settings):
|
||||
- `TwoFactorSetup::confirm()` → if `! hasRecoveryCodes()` generate; then
|
||||
`$dispatch('openModal', …recovery-codes)`.
|
||||
- `WebauthnKeys::register()` → first key + no codes → generate + open the modal.
|
||||
- **Settings → Security:** "Backup-Codes verwalten" → `$dispatch('openModal', …recovery-codes)`
|
||||
(no page link).
|
||||
- **Clearing:** when the **last** factor is removed (no TOTP and no keys), null
|
||||
`two_factor_recovery_codes` (2FA fully off → no recovery to keep). A `User::resetIfNoFactor()`
|
||||
helper centralizes this.
|
||||
|
||||
## 4. Login challenge — adapt to whichever factor(s) exist
|
||||
|
||||
`TwoFactorChallenge`:
|
||||
- `verify()` (code path): only attempt the TOTP check when `hasTotp()`; always try
|
||||
`useRecoveryCode()`. So a **key-only** user with no TOTP can still enter a backup code; a
|
||||
TOTP user is unchanged.
|
||||
- The **TOTP code field** renders only when the pending user `hasTotp()`. The
|
||||
"Mit Security-Key anmelden" button renders when `webauthnAvailable()` (already gated). The
|
||||
backup-code hint shows when the user has codes. A key-only user sees: key button + backup-code
|
||||
entry, no TOTP field.
|
||||
|
||||
## 5. Settings → Security — manage factors
|
||||
|
||||
- **TOTP card:** `Einrichten` (→ `two-factor.setup`) when `! hasTotp()`; `Entfernen` when
|
||||
`hasTotp()`. Removal is **always allowed** now (2FA optional). On removal → null
|
||||
`two_factor_secret`/`two_factor_confirmed_at`, then `resetIfNoFactor()`.
|
||||
- **Security-Keys card:** add/list/remove gated **only** on `WebauthnService::available()`
|
||||
(domain+HTTPS) — drop the `hasTwoFactorEnabled()` requirement so a key can be the **first/only**
|
||||
factor. Removing the last key → `resetIfNoFactor()`.
|
||||
- **"2FA empfohlen" hint** when `! hasTwoFactorEnabled()`.
|
||||
- `disableTwoFactor` handler reframed to "remove TOTP" (+ `resetIfNoFactor()`); it no longer nukes
|
||||
keys (each factor is removed from its own card).
|
||||
|
||||
## 6. forgot-password
|
||||
|
||||
Unchanged logic (2FA-code or backup, email when SMTP). For a **key-only** user there is no TOTP →
|
||||
they use a **backup code**. For a **no-2FA** user the 2FA-proof path is unavailable → the page
|
||||
states recovery needs the email link (if SMTP) or the `clusev:reset-admin` CLI. `clusev:reset-admin`
|
||||
already clears all factors.
|
||||
|
||||
## Files touched
|
||||
|
||||
`app/Models/User.php` (semantics + `resetIfNoFactor`), `EnsureSecurityOnboarded`,
|
||||
`Auth\PasswordChange` (redirect), `Auth\TwoFactorSetup` (modal), `Auth\TwoFactorChallenge`
|
||||
(conditional field/verify), `Settings\Index` (TOTP remove + reset, hint),
|
||||
`Settings\WebauthnKeys` (gate, first-key codes + modal, last-key reset), new
|
||||
`Modals\RecoveryCodes` + view, **remove** `Auth\RecoveryCodes` + its view + the `two-factor.recovery`
|
||||
route, `routes/channels.php`, the relevant Blade (settings security tab, challenge, login),
|
||||
`lang/{de,en}/auth.php`.
|
||||
|
||||
## Testing
|
||||
|
||||
- `hasTwoFactorEnabled` true for TOTP-only, key-only, both; false for neither.
|
||||
- Onboarding: a password-rotated user with no 2FA reaches the dashboard (no forced 2FA redirect);
|
||||
the broadcast gate passes on password rotation alone.
|
||||
- Recovery modal: codes generated on the first factor (TOTP confirm and first key); cleared when the
|
||||
last factor is removed; the modal lists/regenerates.
|
||||
- Challenge: TOTP-only (field shown), key-only (no field; backup code works), both.
|
||||
- Settings: TOTP removable; last-factor removal clears codes; recovery "verwalten" opens the modal.
|
||||
- R12 (bare-IP, DE+EN): Settings shows the TOTP card + the "empfohlen" hint + the key card's
|
||||
domain hint; the recovery modal opens. WebAuthn key E2E stays domain-deferred.
|
||||
- Pint, Codex clean.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing the WebAuthn ceremony itself (v0.6.0) or password rotation enforcement.
|
||||
- A key-or-TOTP **choice wizard** at onboarding — onboarding no longer forces 2FA, so the user just
|
||||
enables what they want from Settings (simpler; avoids a bare-IP-only-TOTP wizard branch).
|
||||
Loading…
Reference in New Issue