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_codes—text()->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 codesStr::random(10).'-'.Str::random(10), save, return them.recoveryCodes(): array— current codes (or[]).hasRecoveryCodes(): bool.useRecoveryCode(string $code): bool— if$codeis 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, callreplaceRecoveryCodes(), thenreturn $this->redirect(route('two-factor.recovery'))instead of straight to the dashboard. - Show-once view:
Auth\RecoveryCodes(routetwo-factor.recovery, auth middleware). Lists the current codes prominently, a Download button (GET /two-factor/recovery-codes/download→text/plainattachmentclusev-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 TOTPverifyKeyfails, try$user->useRecoveryCode($input); if it succeeds, treat as verified (clear rate-limit, login). Same 5/min rate-limit and2fa.usersession gate. View hint: "Authenticator verloren? Backup-Code eingeben." - Settings management: in
Settings\Index(account area), add view-current-codes + "Backup-Codes neu erzeugen" (callsreplaceRecoveryCodes(), re-shows + download). Audited (AuditEventtwo_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; requirehasTwoFactorEnabled(); verifycodeas TOTP (Google2FA::verifyKey) oruseRecoveryCode($code); on success setpassword = Hash::make(...),must_change_password = false, auditpassword.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) + aAuth\ResetPasswordcomponent at/reset-password/{token}(password.reset) that consumes thepassword_reset_tokensrow and sets the new password (same policy). AResetPasswordNotificationprovides the localized mail.
- Primary — 2FA-code reset (always available): fields
- 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\ResetAdmin — clusev: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--passwordomitted — mirrorsInstall),must_change_password = false. - With
--disable-2fa: nulltwo_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-password→Auth\ForgotPassword(password.request);GET /reset-password/{token}→Auth\ResetPassword(password.reset). - auth:
GET /two-factor/recovery-codes→Auth\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:replaceRecoveryCodesreturns 8 unique codes + persists;useRecoveryCodeconsumes 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-2faclears 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.