clusev/docs/superpowers/specs/2026-06-14-account-recovery...

6.6 KiB

Account Recovery — Design (Phase 1)

Date: 2026-06-14 · Branch: feat/v1-foundation · Status: approved

Adds account-recovery to the custom auth: a forgot-password flow, 2FA backup (recovery) codes, and a CLI lockout fallback. WebAuthn/YubiKey is explicitly out of scope here — it gets its own Phase-2 spec (needs laragear/webauthn, JS ceremonies, and HTTPS, which the panel only has once a domain+TLS is configured).

Auth today is hand-rolled (not Fortify): pragmarx/google2fa-qrcode TOTP, Livewire Auth\{Login,TwoFactorSetup,TwoFactorChallenge,PasswordChange}. Users have two_factor_secret (encrypted), two_factor_confirmed_at, must_change_password. Mail is MAIL_MAILER=log (no SMTP) and the panel runs HTTP on bare-IP until a domain is set — so the primary recovery path must not depend on email. Password policy (reused everywhere): Password::min(12)->mixedCase()->numbers(), confirmed.


Data model

Migration add_two_factor_recovery_codes_to_users_table:

  • two_factor_recovery_codestext()->nullable()->after('two_factor_secret').

User:

  • Cast 'two_factor_recovery_codes' => 'encrypted:array' (encrypted-at-rest JSON; plaintext in memory so codes can be re-shown/downloaded in Settings — Fortify-style).
  • replaceRecoveryCodes(): array — set 8 fresh codes Str::random(10).'-'.Str::random(10), save, return them.
  • recoveryCodes(): array — current codes (or []).
  • hasRecoveryCodes(): bool.
  • useRecoveryCode(string $code): bool — if $code is in the list, remove it, save, return true; else false. Trim/normalize input.

1. 2FA backup (recovery) codes

  • Generation: in TwoFactorSetup::confirm(), after saving the secret, call replaceRecoveryCodes(), then return $this->redirect(route('two-factor.recovery')) instead of straight to the dashboard.
  • Show-once view: Auth\RecoveryCodes (route two-factor.recovery, auth middleware). Lists the current codes prominently, a Download button (GET /two-factor/recovery-codes/downloadtext/plain attachment clusev-recovery-codes.txt, current user only), and a "Weiter" button to the dashboard. Reachable both right after setup and from Settings.
  • Challenge accepts a backup code: in TwoFactorChallenge::verify(), after the TOTP verifyKey fails, try $user->useRecoveryCode($input); if it succeeds, treat as verified (clear rate-limit, login). Same 5/min rate-limit and 2fa.user session gate. View hint: "Authenticator verloren? Backup-Code eingeben."
  • Settings management: in Settings\Index (account area), add view-current-codes + "Backup-Codes neu erzeugen" (calls replaceRecoveryCodes(), re-shows + download). Audited (AuditEvent two_factor.recovery_regenerate).

2. Forgot-password flow

  • Login link: add "Passwort vergessen?" → route('password.request') (/forgot-password, guest).
  • Auth\ForgotPassword (guest):
    • Primary — 2FA-code reset (always available): fields email, code (TOTP or backup), password, password_confirmation. On submit: rate-limit by email+IP (5/min); look up the user; require hasTwoFactorEnabled(); verify code as TOTP (Google2FA::verifyKey) or useRecoveryCode($code); on success set password = Hash::make(...), must_change_password = false, audit password.reset, then redirect to login with a success notice. Validation: password => ['required','confirmed', Password::min(12)->mixedCase()->numbers()]. Generic failure messages (don't reveal whether the email exists; uniform "E-Mail oder Code ungültig").
    • Secondary — email link (only when SMTP configured): show this option only if ! in_array(config('mail.default'), ['log','array'], true). Uses Laravel's password broker (Password::sendResetLink) + a Auth\ResetPassword component at /reset-password/{token} (password.reset) that consumes the password_reset_tokens row and sets the new password (same policy). A ResetPasswordNotification provides the localized mail.
  • If the user has no 2FA (onboarding incomplete) and SMTP is off → the page states recovery needs the CLI (Feature 3) and links nothing actionable.

3. CLI lockout fallback

App\Console\Commands\ResetAdminclusev:reset-admin:

  • Signature: {--email= : admin email} {--password= : new password (random if omitted)} {--disable-2fa : also clear 2FA + recovery codes}.
  • Resolves the user by --email (or the sole user if one exists); if none, errors.
  • Sets password = Hash::make($pw) (generate a strong random $pw + print once if --password omitted — mirrors Install), must_change_password = false.
  • With --disable-2fa: null two_factor_secret, two_factor_confirmed_at, two_factor_recovery_codes (operator re-enrolls on next login).
  • Prints a clear confirmation; never logs the password except the one-time stdout print.

Routes

  • guest: GET /forgot-passwordAuth\ForgotPassword (password.request); GET /reset-password/{token}Auth\ResetPassword (password.reset).
  • auth: GET /two-factor/recovery-codesAuth\RecoveryCodes (two-factor.recovery); GET /two-factor/recovery-codes/download → download closure/controller (two-factor.recovery.download).

i18n

All visible strings in lang/{de,en}/auth.php (R16): forgot-password copy, recovery-codes copy (incl. "save these now" warning), challenge backup-code hint, settings regenerate, success / error notices. DE is the source of truth.

Testing

  • Feature/TDD:
    • User: replaceRecoveryCodes returns 8 unique codes + persists; useRecoveryCode consumes a valid code once (second use false) and rejects unknown.
    • Challenge: a recovery code logs in and is then consumed; an invalid code fails + is rate-limited; a TOTP still works.
    • ForgotPassword: valid email+TOTP → password changed + can log in; valid email+backup code → works + code consumed; wrong code → generic error, password unchanged; password policy enforced; email-link option hidden when mail.default = log.
    • CLI clusev:reset-admin: resets password (and with --disable-2fa clears 2FA/codes); no user → non-zero exit.
  • R12 browser (DE+EN, 375/768/1280): login→forgot-password→reset; setup→recovery-codes (download works); challenge with a backup code; settings regenerate. HTTP 200, 0 console, DOM scan (R17).
  • Pint, Codex clean (R15).

Out of scope (Phase-2 spec)

  • WebAuthn/YubiKey as a login second factor (laragear/webauthn, registration + assertion ceremonies, JS, credential storage, HTTPS/domain requirement). Tracked separately.
  • Changing the existing TOTP mechanism or onboarding order.