# 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).