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
parent
a0ef0e6d9b
commit
f0a337c8bd
|
|
@ -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.
|
||||
Loading…
Reference in New Issue