From 10aed49bb6c86a894ebfd9300451836c2409dccd Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Sun, 19 Jul 2026 03:57:13 -0500
Subject: [PATCH] feat(auth): self-service password reset (backend + web)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
---
client/src/App.jsx | 4 +
client/src/api/client.js | 8 +
client/src/routes/player/ForgotPassword.jsx | 74 +++++++
client/src/routes/player/PlayerLogin.jsx | 19 +-
client/src/routes/player/ResetPassword.jsx | 115 ++++++++++
server/db/schema.sql | 21 ++
server/src/middleware/rateLimit.js | 21 ++
.../model/passwordResets/passwordResets.db.js | 46 ++++
.../passwordResets/passwordResets.model.js | 47 +++++
server/src/model/users/users.db.js | 12 ++
server/src/model/users/users.model.js | 9 +
server/src/router/v1/auth/auth.routes.js | 53 ++++-
.../v1/auth/passwordReset.controller.js | 119 +++++++++++
server/src/utils/mailer.js | 34 ++-
server/swagger/swagger-output.json | 196 +++++++++++++++++-
server/test/passwordResets.test.js | 82 ++++++++
16 files changed, 851 insertions(+), 9 deletions(-)
create mode 100644 client/src/routes/player/ForgotPassword.jsx
create mode 100644 client/src/routes/player/ResetPassword.jsx
create mode 100644 server/src/model/passwordResets/passwordResets.db.js
create mode 100644 server/src/model/passwordResets/passwordResets.model.js
create mode 100644 server/src/router/v1/auth/passwordReset.controller.js
create mode 100644 server/test/passwordResets.test.js
diff --git a/client/src/App.jsx b/client/src/App.jsx
index ac320d1..d239798 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -55,6 +55,8 @@ import ModerationUser from './routes/admin/views/ModerationUser.jsx'
// Player portal
import PlayerLogin from './routes/player/PlayerLogin.jsx'
import PlayerRegister from './routes/player/PlayerRegister.jsx'
+import ForgotPassword from './routes/player/ForgotPassword.jsx'
+import ResetPassword from './routes/player/ResetPassword.jsx'
import AcceptInvite from './routes/player/AcceptInvite.jsx'
import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
@@ -166,6 +168,8 @@ export default function App() {
{/* Player portal */}
} />
} />
+ } />
+ } />
} />
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
+ // Self-service password reset (public, token-gated). forgot always resolves the
+ // same way whether or not the email exists (no enumeration); getPasswordReset
+ // validates a link (200 → { username }, 404 → invalid/expired); resetPassword
+ // sets the new password and revokes all sessions (the user then signs in fresh).
+ forgotPassword: (email) => req('/auth/password/forgot', { method: 'POST', body: { email } }),
+ getPasswordReset: (token) => req(`/auth/password/reset/${encodeURIComponent(token)}`),
+ resetPassword: (token, password) =>
+ req(`/auth/password/reset/${encodeURIComponent(token)}`, { method: 'POST', body: { password } }),
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
// the callback, so only the code is sent). Returns { user, returnTo }.
ssoLoginTotp: (code) => req('/auth/sso/totp', { method: 'POST', body: { code } }),
diff --git a/client/src/routes/player/ForgotPassword.jsx b/client/src/routes/player/ForgotPassword.jsx
new file mode 100644
index 0000000..739a586
--- /dev/null
+++ b/client/src/routes/player/ForgotPassword.jsx
@@ -0,0 +1,74 @@
+import { useState } from 'react'
+import { Link } from 'react-router-dom'
+import { api } from '../../api/client.js'
+import PlayerShell from './PlayerShell.jsx'
+
+// Public "forgot password" request page. Submitting emails a tokened reset link to
+// every active account on the address (see ResetPassword for the other half). The
+// server never reveals whether the email exists — it always answers the same way —
+// so this page shows an identical confirmation regardless, to avoid enumeration.
+export default function ForgotPassword() {
+ const [email, setEmail] = useState('')
+ const [error, setError] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [sent, setSent] = useState(false)
+
+ async function onSubmit(e) {
+ e.preventDefault()
+ setError('')
+ if (!/.+@.+\..+/.test(email.trim())) return setError('Enter a valid email address.')
+ setBusy(true)
+ try {
+ await api.forgotPassword(email.trim())
+ setSent(true)
+ } catch (err) {
+ // Only a rate-limit (429) or a real outage surfaces here — a non-match still
+ // returns 200. Keep the message generic either way.
+ if (err.status === 429) setError('Too many requests. Please try again in a little while.')
+ else setError('Could not send the reset email right now. Please try again later.')
+ setBusy(false)
+ }
+ }
+
+ if (sent) {
+ return (
+
+
+ If an account exists for {email.trim()}, we’ve sent a link to
+ reset its password. Check your inbox (and spam) — the link expires in about an hour.
+