Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
84 lines
5.1 KiB
Markdown
84 lines
5.1 KiB
Markdown
# 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).
|