docs(spec): account recovery design (forgot-password, 2FA backup codes, CLI)

Phase 1: 2FA recovery codes (encrypted, show-once + downloadable, usable at the
challenge), a forgot-password flow (2FA-code proof primary, email link only when
SMTP is configured), and a clusev:reset-admin CLI lockout fallback. WebAuthn is
deferred to a Phase-2 spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 16:41:45 +02:00
parent a0ef0e6d9b
commit f0a337c8bd
1 changed files with 119 additions and 0 deletions

View File

@ -0,0 +1,119 @@
# 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.