Files
descrybe/docs/forgot-password.md
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

5.1 KiB

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, ux-backlog.md (P0-10 set-password), live-auth-security.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).


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 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).