diff --git a/docs/superpowers/specs/2026-06-14-optional-pluggable-2fa-design.md b/docs/superpowers/specs/2026-06-14-optional-pluggable-2fa-design.md new file mode 100644 index 0000000..2550038 --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-optional-pluggable-2fa-design.md @@ -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).