From f0a337c8bd1da8385692d55db4714392cfe524a8 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 16:41:45 +0200 Subject: [PATCH] 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) --- .../2026-06-14-account-recovery-design.md | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-14-account-recovery-design.md diff --git a/docs/superpowers/specs/2026-06-14-account-recovery-design.md b/docs/superpowers/specs/2026-06-14-account-recovery-design.md new file mode 100644 index 0000000..c2aff61 --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-account-recovery-design.md @@ -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.