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_atset (the oldhasTwoFactorEnabled).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.downloadendpoint stays — it's not a view), and a Regenerate. Opened via$dispatch('openModal', { component: 'modals.recovery-codes' }). - Remove
Auth\RecoveryCodes(full-page component), its viewlivewire/auth/recovery-codes.blade.php, and thetwo-factor.recoveryroute. 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),
TwoFactorSetupmoves 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). AUser::resetIfNoFactor()helper centralizes this.
4. Login challenge — adapt to whichever factor(s) exist
TwoFactorChallenge:
verify()(code path): only attempt the TOTP check whenhasTotp(); always tryuseRecoveryCode(). 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 whenwebauthnAvailable()(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();EntfernenwhenhasTotp(). Removal is always allowed now (2FA optional). On removal → nulltwo_factor_secret/two_factor_confirmed_at, thenresetIfNoFactor(). - Security-Keys card: add/list/remove gated only on
WebauthnService::available()(domain+HTTPS) — drop thehasTwoFactorEnabled()requirement so a key can be the first/only factor. Removing the last key →resetIfNoFactor(). - "2FA empfohlen" hint when
! hasTwoFactorEnabled(). disableTwoFactorhandler 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
hasTwoFactorEnabledtrue 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).