clusev/docs/superpowers/specs/2026-06-14-optional-pluggab...

7.1 KiB

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