feat(auth): self-service password reset (backend + web) #75

Merged
whitlocktech merged 2 commits from feat/password-reset into main 2026-07-19 09:36:37 +00:00
Member

What & why

Adds a full self-service password-reset flow to the website. This is the hard prerequisite for the Android app (docs/android/PLAN.md §8.2) — the app hands off to the website's reset page rather than shipping a native reset screen, so no mobile reset endpoint is needed.

Backend

  • New password_resets table — stores only the sha256 hash of an opaque 32-byte token (same pattern as user_invites / mobile_refresh_tokens), single-use, ~1h TTL.
  • model/passwordResets/* and users.getActiveByEmail (email is intentionally non-unique, so a request can match several accounts — each is emailed its own link naming its username).
  • mailer.sendPasswordReset (fails soft when email isn't configured).
  • Endpoints:
    • POST /auth/password/forgotalways returns a generic 200 whether or not the email matches (no account enumeration).
    • GET /auth/password/reset/:token — validate a link → { username }, else 404.
    • POST /auth/password/reset/:token — consume the single-use token, rotate the hash, and revoke every session (web cutoff + mobile refresh tokens). Does not auto-login, so a 2FA account still passes TOTP on next sign-in. Also serves SSO-only accounts (null hash) as their "set an initial password" path.
  • Dedicated per-IP request + confirm rate limiters. OpenAPI/Swagger regenerated.

Web

  • ForgotPassword + ResetPassword pages, routes /account/forgot and /account/reset/:token, and a "Forgot your password?" link on the player login page.

How it was tested

  • npm test (server) — all 208 tests pass (5 new in test/passwordResets.test.js).
  • npm run build (client) — builds clean.
  • End-to-end smoketest against the Dockerized MariaDB with the server running: generic no-enumeration response, pending row created, invalid/used token → 404, single-use enforced, password hash rotated, all pending links invalidated, and login with the new password succeeds.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • No AI tools were used to produce this contribution.
  • AI tools were used. Tool(s): Claude Code (Claude Opus 4.8). I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a Co-Authored-By trailer.

License

  • I agree that my contribution is licensed under this project's license (GNU GPL v3.0 or later), and I have the right to contribute it.

Docs counterpart: RunicGateway/docs#(docs/password-reset) updates BACKEND_DESIGN.md for these endpoints + table.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr

## What & why Adds a full self-service **password-reset** flow to the website. This is the hard prerequisite for the Android app (`docs/android/PLAN.md` §8.2) — the app hands off to the website's reset page rather than shipping a native reset screen, so no mobile reset endpoint is needed. **Backend** - New `password_resets` table — stores only the **sha256 hash** of an opaque 32-byte token (same pattern as `user_invites` / `mobile_refresh_tokens`), single-use, ~1h TTL. - `model/passwordResets/*` and `users.getActiveByEmail` (email is intentionally non-unique, so a request can match several accounts — each is emailed its own link naming its username). - `mailer.sendPasswordReset` (fails soft when email isn't configured). - Endpoints: - `POST /auth/password/forgot` — **always returns a generic 200** whether or not the email matches (no account enumeration). - `GET /auth/password/reset/:token` — validate a link → `{ username }`, else 404. - `POST /auth/password/reset/:token` — consume the single-use token, rotate the hash, and **revoke every session** (web cutoff + mobile refresh tokens). Does **not** auto-login, so a 2FA account still passes TOTP on next sign-in. Also serves SSO-only accounts (null hash) as their "set an initial password" path. - Dedicated per-IP request + confirm rate limiters. OpenAPI/Swagger regenerated. **Web** - `ForgotPassword` + `ResetPassword` pages, routes `/account/forgot` and `/account/reset/:token`, and a "Forgot your password?" link on the player login page. ## How it was tested - `npm test` (server) — **all 208 tests pass** (5 new in `test/passwordResets.test.js`). - `npm run build` (client) — builds clean. - **End-to-end smoketest** against the Dockerized MariaDB with the server running: generic no-enumeration response, pending row created, invalid/used token → 404, single-use enforced, password hash rotated, all pending links invalidated, and **login with the new password succeeds**. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [ ] No AI tools were used to produce this contribution. - [x] AI tools were used. Tool(s): `Claude Code (Claude Opus 4.8)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it. --- Docs counterpart: RunicGateway/docs#(docs/password-reset) updates `BACKEND_DESIGN.md` for these endpoints + table. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
wtclaude added 1 commit 2026-07-19 08:58:01 +00:00
feat(auth): self-service password reset (backend + web)
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m45s
PR Checks / server-tests (pull_request) Successful in 10m42s
PR Checks / bot-install (pull_request) Successful in 9m21s
10aed49bb6
Add a full password-reset flow — the prerequisite for the Android app
(docs/android/PLAN.md §8.2), which hands off to the website for reset
rather than shipping a native screen.

Backend:
- password_resets table: stores only the sha256 hash of an opaque 32-byte
  token (mirrors user_invites / mobile_refresh_tokens), single-use, ~1h TTL.
- model/passwordResets + users.getActiveByEmail (email is non-unique, so a
  request can match several accounts, each emailed its own link).
- mailer.sendPasswordReset (fails soft when email is unconfigured).
- Endpoints: POST /auth/password/forgot (always a generic 200 — no account
  enumeration), GET|POST /auth/password/reset/:token. Confirming rotates the
  hash and revokes every session (web cutoff + mobile refresh tokens); it does
  not auto-login, so a 2FA account still passes TOTP next sign-in. Also serves
  SSO-only accounts (null hash) as their set-initial-password path.
- Dedicated request/confirm rate limiters. Swagger regenerated.

Web:
- ForgotPassword + ResetPassword pages, routes /account/forgot and
  /account/reset/:token, and a "Forgot your password?" link on the login page.

Tests: test/passwordResets.test.js (5). All server tests pass; client builds;
end-to-end smoketest against MariaDB passes (no-enumeration, single-use, hash
rotation, session revoke, login with the new password).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
whitlocktech approved these changes 2026-07-19 09:04:35 +00:00
whitlocktech added 1 commit 2026-07-19 09:04:47 +00:00
Merge branch 'main' into feat/password-reset
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m59s
PR Checks / client-build (pull_request) Successful in 9m26s
PR Checks / bot-install (pull_request) Successful in 9m31s
250cb1e2d3
whitlocktech merged commit 715eaedd74 into main 2026-07-19 09:36:37 +00:00
whitlocktech deleted branch feat/password-reset 2026-07-19 09:36:37 +00:00
Sign in to join this conversation.
No description provided.