# 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 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/download`
  → `text/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\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
  `--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-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`: `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.
