Customer password reset via email round-trip #32

Closed
opened 2026-08-17 17:09:32 -05:00 by bermudalamb · 1 comment
Owner

Split out of #31 (items 1 and 2).

Goal

A customer who has forgotten their password can recover access without contacting anyone.

  • A "Forgot password?" link on the login page
  • Entering an email sends a reset link
  • Following the link lets them set a new password
  • The old password stops working, and existing sessions are considered

Existing pieces this builds on

  • customer_tokens already has a kind discriminator, currently only verify_email — a password_reset kind fits the same table and expiry pattern
  • sendMail already degrades gracefully when SMTP is unconfigured, logging a warning and skipping the send
  • /verify-email already establishes the pattern for a token-carrying page

Design questions to resolve before implementing

  • Does requesting a reset for an unknown email report "no such account", or always report success? (Reporting failure enumerates which emails have accounts.)
  • Token lifetime, and whether requesting a new reset invalidates earlier ones
  • Whether completing a reset should terminate the customer's other active sessions
  • Whether a reset should also mark an unverified email as verified, since the customer has demonstrably received mail at that address
  • Rate limiting, so the endpoint cannot be used to send unlimited mail to an address

Blocks

Passkey login (#31 item 6) cannot ship without account recovery, so this comes first.

Split out of #31 (items 1 and 2). ## Goal A customer who has forgotten their password can recover access without contacting anyone. - A "Forgot password?" link on the login page - Entering an email sends a reset link - Following the link lets them set a new password - The old password stops working, and existing sessions are considered ## Existing pieces this builds on - `customer_tokens` already has a `kind` discriminator, currently only `verify_email` — a `password_reset` kind fits the same table and expiry pattern - `sendMail` already degrades gracefully when SMTP is unconfigured, logging a warning and skipping the send - `/verify-email` already establishes the pattern for a token-carrying page ## Design questions to resolve before implementing - Does requesting a reset for an unknown email report "no such account", or always report success? (Reporting failure enumerates which emails have accounts.) - Token lifetime, and whether requesting a new reset invalidates earlier ones - Whether completing a reset should terminate the customer's other active sessions - Whether a reset should also mark an unverified email as verified, since the customer has demonstrably received mail at that address - Rate limiting, so the endpoint cannot be used to send unlimited mail to an address ## Blocks Passkey login (#31 item 6) cannot ship without account recovery, so this comes first.
bermudalamb added this to the Initial Build project 2026-08-17 17:21:02 -05:00
bermudalamb self-assigned this 2026-08-17 17:22:09 -05:00
Author
Owner

Implemented

Branch feature/password-reset, one commit, not yet pushed.

Decisions taken

Question Answer
Unknown address Always report success. Identical response and wording either way
Sessions on reset Terminate all of them, then sign in fresh on this device
Rate limiting express-rate-limit — first rate limiting in the codebase
Token lifetime 1 hour, single use
Repeat requests A new request supersedes any outstanding token
Unverified address Marked verified on reset — receiving the mail is what verification proves

Flow

Forgot password? on the login page → /forgot-password → email → /reset-password?token=… → new password → signed in on /account.

Reuses customer_tokens with a password_reset kind alongside verify_email, and the existing sendMail, which still degrades gracefully when SMTP is unconfigured.

Two things worth flagging

The limiter had a real design bug, caught by the tests. It keys on caller and submitted address — keying on IP alone would let one person lock out everyone behind the same proxy, and everything arrives via Nginx Proxy Manager. But /reset-password carries no address, so applying the same limiter there collapsed every customer completing a reset into one shared bucket of five per fifteen minutes. That endpoint is now deliberately unlimited: the token is 32 random bytes, single-use, hour-limited, and the expensive bcrypt hash only runs after the token matches, so invalid guesses cost one indexed lookup.

The e2e tests read the issued token straight from Postgres, not through a test-support endpoint. I started to add GET /api/test-support/latest-reset-token and stopped: an endpoint that returns a password-reset token for an arbitrary address is account takeover for every customer if it is ever reachable, and an environment gate is thin protection against that. Reading it inside the test process keeps the capability with zero production surface.

Verification

Suite Result
Backend unit 52 passed
Backend integration 89 passed (15 new)
Frontend e2e 53 passed (7 new), stable over two consecutive runs
tsc --noEmit backend / frontend clean
npm run build clean

Integration coverage includes the enumeration behavior, token supersession, cross-kind rejection (a verify_email token cannot stand in for a reset), replay, expiry, session eviction, and that a rejected short password does not burn the customer's only token.

Follow-up worth its own issue

/register still returns 409 an account with this email already exists, which makes registration an account-enumeration oracle and partly defeats the protection added here. Out of scope for this issue, but the two belong together.

## Implemented Branch `feature/password-reset`, one commit, not yet pushed. ### Decisions taken | Question | Answer | | --- | --- | | Unknown address | **Always report success.** Identical response and wording either way | | Sessions on reset | **Terminate all of them**, then sign in fresh on this device | | Rate limiting | **`express-rate-limit`** — first rate limiting in the codebase | | Token lifetime | 1 hour, single use | | Repeat requests | A new request supersedes any outstanding token | | Unverified address | Marked verified on reset — receiving the mail is what verification proves | ### Flow `Forgot password?` on the login page → `/forgot-password` → email → `/reset-password?token=…` → new password → signed in on `/account`. Reuses `customer_tokens` with a `password_reset` kind alongside `verify_email`, and the existing `sendMail`, which still degrades gracefully when SMTP is unconfigured. ### Two things worth flagging **The limiter had a real design bug, caught by the tests.** It keys on caller *and* submitted address — keying on IP alone would let one person lock out everyone behind the same proxy, and everything arrives via Nginx Proxy Manager. But `/reset-password` carries no address, so applying the same limiter there collapsed every customer completing a reset into one shared bucket of five per fifteen minutes. That endpoint is now deliberately unlimited: the token is 32 random bytes, single-use, hour-limited, and the expensive bcrypt hash only runs *after* the token matches, so invalid guesses cost one indexed lookup. **The e2e tests read the issued token straight from Postgres**, not through a test-support endpoint. I started to add `GET /api/test-support/latest-reset-token` and stopped: an endpoint that returns a password-reset token for an arbitrary address is account takeover for every customer if it is ever reachable, and an environment gate is thin protection against that. Reading it inside the test process keeps the capability with zero production surface. ### Verification | Suite | Result | | --- | --- | | Backend unit | 52 passed | | Backend integration | 89 passed (15 new) | | Frontend e2e | 53 passed (7 new), stable over two consecutive runs | | `tsc --noEmit` backend / frontend | clean | | `npm run build` | clean | Integration coverage includes the enumeration behavior, token supersession, cross-kind rejection (a `verify_email` token cannot stand in for a reset), replay, expiry, session eviction, and that a rejected short password does **not** burn the customer's only token. ### Follow-up worth its own issue `/register` still returns `409 an account with this email already exists`, which makes registration an account-enumeration oracle and partly defeats the protection added here. Out of scope for this issue, but the two belong together.
bermudalamb moved this to In Progress in Initial Build on 2026-08-17 18:55:55 -05:00
bermudalamb moved this to Ready for Review in Initial Build on 2026-08-17 18:56:39 -05:00
bermudalamb moved this to Review in Initial Build on 2026-08-18 11:09:45 -05:00
bermudalamb moved this to Ready for Release in Initial Build on 2026-08-18 11:09:51 -05:00
bermudalamb moved this to Released in Initial Build on 2026-08-18 11:16:13 -05:00
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bermudalamb/redefined-designs#32