Files
descrybe/docs/forgot-password.md
T

84 lines
5.1 KiB
Markdown
Raw Normal View History

# Self-serve forgot-password — gap & recommended design
**Assessment date:** 2026-08-08
**Verdict:** **Out of scope for a minimal safe reuse.** Document only until a dedicated reset feature is approved.
**Mail infra:** Existing SMTP/`mail.Send` is enough; **do not** build a new mail stack. Confirm product copy/TTL/rate limits before implementing.
Related: [status-and-gaps.md](status-and-gaps.md), [ux-backlog.md](ux-backlog.md) (P0-10 set-password), [live-auth-security.md](live-auth-security.md), [migration-readiness.md](migration-readiness.md).
---
## What exists today (not forgot-password)
| Piece | Role | Why it cannot serve “I forgot my password” |
|-------|------|--------------------------------------------|
| `must_set_password` + `auth.SetPassword` | First password after migration / bootstrap | Update requires `must_set_password = true`; returns `ErrPasswordAlreadySet` otherwise |
| `ReissueSetPasswordInvite` | Admin re-issue durable invite | Requires `must_set_password` + active membership; ineligible for accounts that already set a password |
| `AcceptInvite` | Invite / first-password accept | Existing users with password must **verify** current password — does not reset |
| HMAC `IssueSetPasswordToken` + `handleCompleteSetPassword` | Token complete for first-set | Calls `SetPassword` → same `must_set_password` gate |
| Admin `POST …/admin/…` set-password email | Operator-only bulk/single send | AuthZ + eligibility same as above; login CTA points here (no public resend) — see P0-10 |
| `mail.SetPasswordMessage` / `MigratedSetPasswordMessage` | Email copy + `/accept-invite` links | Templates/links are first-set oriented; reusable **patterns** only |
| Login UI | `password_not_set` CTA → `/accept-invite` + admin Users | No “Forgot password?” link |
**Operator workarounds today:** platform admin re-issues set-password for `must_set_password` users; local/dev `ForceSetPassword` / migrator `-set-password` for bootstrap. Neither is self-serve recovery for users who already know they had a password.
---
## Why a “small patch” is unsafe
Reusing invite/HMAC set-password for established accounts would require relaxing `must_set_password` (or calling `ForceSetPassword` from a public token path). That collapses first-set and reset semantics, weakens single-use guarantees for HMAC tokens (no DB row until success), and risks account takeover if the public request endpoint is naively bolted on.
Intentional product gap (Wave 8): **no public self-serve resend** for set-password (P0-10).
---
## Recommended design (when approved)
Reuse **mail delivery and rate-limit patterns** from admin set-password; add a **separate** reset purpose. Do not overload `must_set_password` / `AcceptInvite`.
### API (sketch)
1. **`POST /api/auth/forgot-password`** `{ "email": "…" }`
- Always return the same opaque success (anti-enumeration).
- Rate-limit by IP + normalized email (mirror admin set-password limiters).
- If active user with deliverable email: issue **reset** token (prefer durable hashed row, invite-style; HMAC-only only if single-use store is added).
- Skip synthetic `@legacy.local` silently.
- Send `ForgotPasswordMessage` (new; link to `/reset-password?token=…`, not accept-invite first-set mode).
2. **`POST /api/auth/reset-password`** `{ "token", "password" }`
- Validate token; set new argon2id hash for that user **regardless of** `must_set_password` (dedicated `ResetPassword`, not `SetPassword` / not public `ForceSetPassword`).
- Invalidate token; optionally revoke other sessions.
- Clear `must_set_password` if still set.
### Web
- Login: “Forgot password?” → `/forgot-password`.
- `/forgot-password`: email form + generic confirmation copy.
- `/reset-password`: password + confirm; strip `?token=` from URL like accept-invite.
### Security checklist
- Constant-time / uniform responses and timing where practical.
- Token TTL short (e.g. 1h); one-time consume.
- CSRF on cookie-authenticated POSTs; public forgot/reset still need CSRF if under same cookie middleware.
- No token in list APIs or HTML; prefer `#token=` / exchange code later ([live-auth-security.md](live-auth-security.md) residual).
- Tests: happy path, expired/reuse, unknown email response shape, rate limit, inactive user, synthetic email.
### Explicitly out of this design
- New mail provider, queue, or template engine (extend `internal/mail`).
- Turning admin set-password into a public endpoint.
- Logged-in “change password” (optional follow-up; separate from forgot).
---
## Implementation status
| Item | Status |
|------|--------|
| Self-serve forgot / reset | **Implemented** (`041_password_reset_tokens`, `POST /api/auth/forgot-password`, `POST /api/auth/reset-password`, `/forgot-password` + `/reset-password`, i18n) |
| Migration first-set + admin re-issue | Implemented (P0-10) |
| Mail SMTP send path | Implemented; forgot-password reuses `mail.Send` + `EMAIL_DRY_RUN` / `ApplyDryRun` |
**ASSUMPTION:** Opaque `{status:"ok"}` responses; TTL **1h**; IP 10/min + email 3/hour limiters; no session revoke on reset (optional follow-up).