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