screentinker/server/services
ScreenTinker b7d55595af feat(auth): self-service password reset
Until now the only ways back into an account were an admin setting your password for you
or shell access to run scripts/reset-admin.js. A self-hosted operator who forgot their
password had no path at all, and the admin-reset route explicitly refuses to reset a
platform admin's password — so a single-admin instance was unrecoverable without a shell.
The per-account login lockout added recently makes that sharper: a user who forgets their
password will hit the lockout and see the same generic error, with no way out.

Two unauthenticated endpoints (they must be — the user cannot log in):

  POST /api/auth/forgot-password   { email }             -> always the same 200
  POST /api/auth/reset-password    { token, password }   -> 200 / 400

The properties that matter, each covered by a test:

- NO ENUMERATION. The request endpoint answers identically — same status, same body —
  for a real address, an unknown one, an SSO identity with no local password, and a
  malformed string. The frontend shows the same confirmation even on a network error,
  so the client cannot leak what the server refused to.
- NO MFA BYPASS. Completing a reset does NOT issue a session; the user signs in
  afterwards, so a TOTP-enabled account still clears its second factor. Returning a token
  here would turn "read one email" into a full session without the second factor.
- SINGLE USE, SHORT LIVED. 32 random bytes, stored only as a SHA-256 hash (same
  discipline as email verification, recovery codes and API tokens), 1h TTL, and the
  redeeming UPDATE is conditioned on the hash still being present so concurrent
  redemptions cannot both win.
- LOCAL ACCOUNTS ONLY. SSO identities have no local password; no token is minted.
- IT ACTUALLY UNBLOCKS YOU. A completed reset clears the per-account login lockout and
  must_change_password, otherwise someone who locked themselves out would reset and still
  be locked out.

Rate limited: 5/min on the request (it sends mail to a caller-supplied address), 10/min
on the redeem. If no email transport is configured the response is unchanged — no oracle —
but the server logs loudly, because the user will otherwise wait for mail that cannot
arrive and the generic response cannot tell them.

Frontend: a "Forgot your password?" link on the sign-in card, a request card, and a
new-password card. app.js had to learn #/reset-password explicitly — the auth guard
rewrites any unauthenticated hash to #/login, which would have discarded the one-time
token in the emailed link and made it silently do nothing.

Migration adds users.password_reset_hash / password_reset_expires: additive, nullable,
idempotent; a code-only rollback leaves two dead columns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 11:19:39 -05:00
..
activationNudge.js feat(signup): T+3 activation nudge for users with zero paired screens 2026-05-30 20:28:24 -05:00
activity.js Merge branch 'fix/recovery-grants' into release/auth-campaign 2026-07-26 16:22:59 -05:00
agency-digest.js feat(api): batched email digest for agency uploads (#73) 2026-06-14 13:59:37 -05:00
alerts.js feat(email): Microsoft Graph send + alert spam protection + preferences UI 2026-05-12 18:16:40 -05:00
content-expiry.js feat(device-owner): tier foundation + QR provisioning + content-expiry & device enhancements (#168) 2026-07-12 19:41:07 -05:00
email.js feat(email): SMTP transport as an alternative to Microsoft Graph [#173] (#179) 2026-07-13 15:56:22 -05:00
heartbeat.js feat(diagnostics): device incident log — offline cause, network-vs-reboot, display-sleep (#175) 2026-07-13 11:26:04 -05:00
loop-lag.js fix(#146) P3.7: coalescer carries the PEAK numeric over the window 2026-06-30 22:10:45 -05:00
scheduler.js feat(device-owner): tier foundation + QR provisioning + content-expiry & device enhancements (#168) 2026-07-12 19:41:07 -05:00
signupEmails.js feat(auth): self-service password reset 2026-07-27 11:19:39 -05:00