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

+

+ Back to sign in +

+
+ ) + } + + return ( + + Remembered it?{' '} + Sign in +

+ } + > +

+ Enter the email on your account and we’ll send you a link to choose a new password. +

+
+ + + {error &&

{error}

} + + +
+
+ ) +} diff --git a/client/src/routes/player/PlayerLogin.jsx b/client/src/routes/player/PlayerLogin.jsx index 8e054e6..5e898d6 100644 --- a/client/src/routes/player/PlayerLogin.jsx +++ b/client/src/routes/player/PlayerLogin.jsx @@ -122,14 +122,21 @@ export default function PlayerLogin() { - New here?{' '} - - Create an account +
+

+ + Forgot your password?

- ) + {canRegister && ( +

+ New here?{' '} + + Create an account + +

+ )} +
} >
diff --git a/client/src/routes/player/ResetPassword.jsx b/client/src/routes/player/ResetPassword.jsx new file mode 100644 index 0000000..d84c489 --- /dev/null +++ b/client/src/routes/player/ResetPassword.jsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { api } from '../../api/client.js' +import PlayerShell from './PlayerShell.jsx' + +// Public, token-gated reset page (/account/reset/:token). Validates the link, lets +// the user choose a new password, then sends them to sign in fresh. Setting the +// password revokes every existing session (web + mobile) server-side and does NOT +// log them in here — so a 2FA account still passes TOTP on the next sign-in. +export default function ResetPassword() { + const { token } = useParams() + const navigate = useNavigate() + + const [username, setUsername] = useState(null) // whose account this link is for + const [loadErr, setLoadErr] = useState('') + + const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const [done, setDone] = useState(false) + + useEffect(() => { + let active = true + api.getPasswordReset(token) + .then((r) => active && setUsername(r?.username || '')) + .catch((err) => active && setLoadErr( + err.status === 404 ? 'This reset link is invalid or has expired.' : 'Could not load this reset link.', + )) + return () => { active = false } + }, [token]) + + async function onSubmit(e) { + e.preventDefault() + setError('') + if (password.length < 8) return setError('Password must be at least 8 characters.') + if (password !== confirm) return setError('The passwords do not match.') + setBusy(true) + try { + await api.resetPassword(token, password) + setDone(true) + } catch (err) { + if (err.status === 404) setError('This reset link is invalid or has already been used.') + else if (err.status === 429) setError('Too many attempts. Please try again in a little while.') + else if (err.status === 400) setError(err.message || 'Please check your password and try again.') + else setError('Could not reset your password right now. Please try again later.') + setBusy(false) + } + } + + // ── Invalid link ─────────────────────────────────────────────────────────── + if (loadErr) { + return ( + +

{loadErr}

+

+ Request a new link +

+
+ ) + } + if (username === null) { + return ( + +
+
+ ) + } + + // ── Done ─────────────────────────────────────────────────────────────────── + if (done) { + return ( + +

+ Your password has been reset. For your security, every existing session has been signed out. +

+ +
+ ) + } + + // ── Reset form ───────────────────────────────────────────────────────────── + return ( + +

+ Choose a new password{username ? <> for {username} : null}. +

+ + {/* A hidden username field helps password managers associate the credential. */} + {username ? : null} + + + + {error &&

{error}

} + + + +
+ ) +} diff --git a/server/db/schema.sql b/server/db/schema.sql index 683c6cb..fa083ba 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -536,6 +536,27 @@ CREATE TABLE IF NOT EXISTS user_invites ( INDEX idx_user_invites_status (status, expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Self-service password resets. A user requests a reset by email; a tokened link +-- is emailed to every active account on that address. Opening the link and setting +-- a new password rotates the hash and revokes all sessions (web + mobile). Only the +-- sha256 hash of the opaque token is stored — a DB read never yields a usable link, +-- same as user_invites / mobile_refresh_tokens. Single-use + short-lived (1h, +-- enforced in the model on top of expires_at). Also serves SSO-only accounts (null +-- password_hash) as their "set an initial password" path. +CREATE TABLE IF NOT EXISTS password_resets ( + id INT AUTO_INCREMENT PRIMARY KEY, + token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque token + user_id INT NOT NULL, -- the account this reset targets + status ENUM('pending','used') NOT NULL DEFAULT 'pending', + requested_ip VARCHAR(64) NULL, -- who asked (audit only) + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + used_at DATETIME NULL, + CONSTRAINT fk_password_resets_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_password_resets_user (user_id), + INDEX idx_password_resets_status (status, expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Discord bot moderation core (Phase 2). These tables are owned by the bot -- process (its own DB pool, bot/src/db.js) — the main server never reads or -- writes them. They live in the same physical database as everything else diff --git a/server/src/middleware/rateLimit.js b/server/src/middleware/rateLimit.js index ca048c5..fa00683 100644 --- a/server/src/middleware/rateLimit.js +++ b/server/src/middleware/rateLimit.js @@ -71,6 +71,25 @@ const ssoStartLimiter = makeLimiter({ message: 'Too many sign-in attempts. Please try again later.', }) +// Password-reset requests per IP. Each one can send email, so cap tighter than +// login to blunt email-bombing and enumeration timing probes. The endpoint always +// returns a generic success regardless of match, so honest users never see this. +const passwordResetRequestLimiter = makeLimiter({ + windowMs: 60 * 60 * 1000, + max: 5, + label: 'password-reset-request', + message: 'Too many reset requests. Please try again later.', +}) + +// Reset confirmations (token + new password) per IP. A wrong/expired token is a +// guessing surface; the token itself is 256-bit random, but cap anyway. +const passwordResetConfirmLimiter = makeLimiter({ + windowMs: 15 * 60 * 1000, + max: 15, + label: 'password-reset-confirm', + message: 'Too many attempts. Please try again later.', +}) + module.exports = { loginLimiter, registerLimiter, @@ -78,4 +97,6 @@ module.exports = { contactLimiter, mobileRefreshLimiter, ssoStartLimiter, + passwordResetRequestLimiter, + passwordResetConfirmLimiter, } diff --git a/server/src/model/passwordResets/passwordResets.db.js b/server/src/model/passwordResets/passwordResets.db.js new file mode 100644 index 0000000..16947ce --- /dev/null +++ b/server/src/model/passwordResets/passwordResets.db.js @@ -0,0 +1,46 @@ +const { query } = require('../../utils/db') + +const COLS = 'id, token_hash, user_id, status, requested_ip, expires_at, created_at, used_at' + +async function insert({ tokenHash, userId, requestedIp, expiresAt }) { + const res = await query( + `INSERT INTO password_resets (token_hash, user_id, requested_ip, expires_at) + VALUES (?, ?, ?, ?)`, + [tokenHash, userId, requestedIp ?? null, expiresAt], + ) + return res.insertId +} + +async function getById(id) { + const rows = await query(`SELECT ${COLS} FROM password_resets WHERE id = ? LIMIT 1`, [id]) + return rows[0] || null +} + +async function findByTokenHash(tokenHash) { + const rows = await query(`SELECT ${COLS} FROM password_resets WHERE token_hash = ? LIMIT 1`, [tokenHash]) + return rows[0] || null +} + +// Mark used only if still pending (atomic guard against a double-use race). +// Returns rows changed (1 = we won, 0 = already used). +async function markUsed(id) { + const res = await query( + `UPDATE password_resets SET status = 'used', used_at = NOW() + WHERE id = ? AND status = 'pending'`, + [id], + ) + return res.affectedRows || 0 +} + +// Invalidate any still-pending resets for a user (e.g. after a successful reset, +// or when a fresh request supersedes older links). Idempotent. +async function invalidatePendingForUser(userId) { + const res = await query( + `UPDATE password_resets SET status = 'used', used_at = NOW() + WHERE user_id = ? AND status = 'pending'`, + [userId], + ) + return res.affectedRows || 0 +} + +module.exports = { insert, getById, findByTokenHash, markUsed, invalidatePendingForUser } diff --git a/server/src/model/passwordResets/passwordResets.model.js b/server/src/model/passwordResets/passwordResets.model.js new file mode 100644 index 0000000..834f51d --- /dev/null +++ b/server/src/model/passwordResets/passwordResets.model.js @@ -0,0 +1,47 @@ +// Self-service password resets. A user asks for a reset by email; a tokened link +// is emailed to every active account on that address. Opening the link and choosing +// a new password rotates the hash and revokes every session. The opaque token lives +// only in the emailed link — the DB stores just its sha256 hash (like user_invites +// and mobile refresh tokens), so a DB read never yields a usable reset link. Tokens +// are single-use and short-lived. + +const crypto = require('crypto') +const db = require('./passwordResets.db') + +// Short by design: a recovery link is a live credential-reset capability, so it +// should not linger the way a 7-day invite does. +const DEFAULT_TTL_MINUTES = 60 + +function hashToken(raw) { + return crypto.createHash('sha256').update(String(raw)).digest('hex') +} + +// Create a reset for a specific user. Returns { id, token } — the plaintext token +// is returned ONCE (for the email link) and never stored or recoverable afterwards. +async function create({ userId, requestedIp, ttlMinutes = DEFAULT_TTL_MINUTES }) { + const token = crypto.randomBytes(32).toString('base64url') + const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000) + const id = await db.insert({ tokenHash: hashToken(token), userId, requestedIp, expiresAt }) + return { id, token } +} + +// Resolve a pending, unexpired reset from its plaintext token, else null. Returns +// the RAW row (incl. user_id) for the confirm flow. +async function findValidByToken(token) { + if (!token) return null + const row = await db.findByTokenHash(hashToken(token)) + if (!row || row.status !== 'pending') return null + if (new Date(row.expires_at).getTime() < Date.now()) return null + return row +} + +// Atomically consume a pending reset (double-use-safe). Returns true if this call +// won the race and marked the token used. +async function consume(id) { + return (await db.markUsed(id)) === 1 +} + +// Retire any other pending links for this user after a successful reset. +const invalidatePendingForUser = (userId) => db.invalidatePendingForUser(userId) + +module.exports = { create, findValidByToken, consume, invalidatePendingForUser, hashToken, DEFAULT_TTL_MINUTES } diff --git a/server/src/model/users/users.db.js b/server/src/model/users/users.db.js index e506106..fd888cb 100644 --- a/server/src/model/users/users.db.js +++ b/server/src/model/users/users.db.js @@ -31,6 +31,17 @@ async function findById(id) { return rows[0] || null } +// All ACTIVE accounts on an email address. Email is intentionally non-unique +// (SSO emails may repeat), so a reset request can legitimately match several +// accounts; the caller issues one reset link per row. Case-insensitive to match +// however the address was stored. Excludes disabled/banned accounts. +async function findActiveByEmail(email) { + return query( + "SELECT * FROM users WHERE email = ? AND status = 'active'", + [email], + ) +} + async function listUsers() { return query(`SELECT ${PUBLIC_COLS} FROM users ORDER BY id ASC`) } @@ -100,6 +111,7 @@ module.exports = { insertUser, findByUsername, findById, + findActiveByEmail, listUsers, updateUser, deleteUser, diff --git a/server/src/model/users/users.model.js b/server/src/model/users/users.model.js index 42322b1..cee8717 100644 --- a/server/src/model/users/users.model.js +++ b/server/src/model/users/users.model.js @@ -34,6 +34,14 @@ async function getById(id) { return sanitize(await usersDb.findById(id)) } +// Raw rows (incl. email/status) for every active account on an email address. +// Server-side only (password-reset request); email is non-unique so this may +// return several. Never sent to a client. +async function getActiveByEmail(email) { + if (!email) return [] + return usersDb.findActiveByEmail(String(email).trim()) +} + // Raw row incl. totp_secret — server-side only (TOTP setup/verify). Never sent // to a client; sanitize() strips the secret from anything user-facing. async function getRawById(id) { @@ -109,6 +117,7 @@ module.exports = { isDuplicateUsername, getRawByUsername, getById, + getActiveByEmail, getRawById, validatePassword, list, diff --git a/server/src/router/v1/auth/auth.routes.js b/server/src/router/v1/auth/auth.routes.js index 031b9e0..9ea8095 100644 --- a/server/src/router/v1/auth/auth.routes.js +++ b/server/src/router/v1/auth/auth.routes.js @@ -3,9 +3,15 @@ const { body, param } = require('express-validator') const { login, register, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller') const { getInvite, acceptInvite } = require('./invite.controller') +const { requestReset, lookupReset, confirmReset } = require('./passwordReset.controller') const { isLoggedIn } = require('../../../utils/auth') const { attachSession } = require('../../../auth/session.middleware') -const { loginLimiter, registerLimiter } = require('../../../middleware/rateLimit') +const { + loginLimiter, + registerLimiter, + passwordResetRequestLimiter, + passwordResetConfirmLimiter, +} = require('../../../middleware/rateLimit') const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection') const validate = require('../../../middleware/validate') const mobileRouter = require('./mobile.routes') @@ -120,6 +126,51 @@ authRouter.post( acceptInvite, ) +// ── Self-service password reset (public, token-gated) ────────────────────── +// Request → email a tokened link; then validate the link and set a new password. +// The request step never reveals whether an email exists (always 200, generic). +authRouter.post( + '/password/forgot', + // #swagger.tags = ['Auth'] + // #swagger.summary = 'Request a password-reset link by email' + // #swagger.description = 'Emails a single-use, ~1h reset link to every active account on the address. Always returns the same generic 200 whether or not the email matches (no account enumeration). Email is non-unique, so multiple accounts may each receive a link naming their username. Rate limited per IP.' + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email"], properties: { email: { type: "string", format: "email" } } } } } } */ + /* #swagger.responses[200] = { description: 'Generic acknowledgement (sent if the account exists)', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[429] = { description: 'Too many requests', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + passwordResetRequestLimiter, + body('email').isString().trim().isEmail().isLength({ max: 255 }), + validate, + requestReset, +) +authRouter.get( + '/password/reset/:token', + // #swagger.tags = ['Auth'] + // #swagger.summary = 'Validate a password-reset link' + // #swagger.description = 'Returns the target username for a valid, pending, unexpired reset link so the reset form can render. 404 for anything not currently usable (never distinguishes expired from used from never-existed).' + /* #swagger.responses[200] = { description: 'Reset link is valid', content: { "application/json": { schema: { type: "object", properties: { username: { type: "string" } } } } } } */ + /* #swagger.responses[404] = { description: 'Invalid or expired reset link', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('token').isString().isLength({ min: 8, max: 128 }), + validate, + lookupReset, +) +authRouter.post( + '/password/reset/:token', + // #swagger.tags = ['Auth'] + // #swagger.summary = 'Set a new password from a reset link' + // #swagger.description = 'Consumes the single-use link and sets the new password. Rotates the hash and revokes every existing session (web + mobile). Does NOT sign the user in — they log in fresh afterwards (so a 2FA account still passes TOTP). Rate limited per IP.' + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["password"], properties: { password: { type: "string", minLength: 8, maxLength: 64 } } } } } } */ + /* #swagger.responses[200] = { description: 'Password changed', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[404] = { description: 'Invalid, expired, or already-used reset link', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[429] = { description: 'Too many attempts', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + passwordResetConfirmLimiter, + param('token').isString().isLength({ min: 8, max: 128 }), + body('password').isString().isLength({ min: 8, max: 64 }), + validate, + confirmReset, +) + authRouter.post( '/logout', // #swagger.tags = ['Auth'] diff --git a/server/src/router/v1/auth/passwordReset.controller.js b/server/src/router/v1/auth/passwordReset.controller.js new file mode 100644 index 0000000..7f170b7 --- /dev/null +++ b/server/src/router/v1/auth/passwordReset.controller.js @@ -0,0 +1,119 @@ +// ── Self-service password reset (public, token-gated) ────────────────────── +// +// Three steps, all unauthenticated: +// 1. POST /auth/password/forgot { email } → email a tokened link +// 2. GET /auth/password/reset/:token → validate the link (for the form) +// 3. POST /auth/password/reset/:token { password } → set the new password +// +// Email is intentionally non-unique (SSO emails may repeat), so a request can +// match several accounts; each gets its own link, and the email names the +// username so the recipient knows which account it's for. The request step NEVER +// reveals whether an address exists — it always returns the same generic success +// (no user enumeration). Only the sha256 hash of each opaque token is stored, so a +// DB read never yields a usable link (same pattern as user_invites). Tokens are +// single-use + expire in ~1h. Setting a new password rotates the hash and revokes +// every session (web cookie cutoff + mobile refresh tokens). We do NOT auto-log-in +// afterwards: the user signs in fresh, so a 2FA account still passes TOTP. + +const passwordResets = require('../../../model/passwordResets/passwordResets.model') +const users = require('../../../model/users/users.model') +const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model') +const activity = require('../../../model/activity/activity.model') +const mailer = require('../../../utils/mailer') + +const log = require('../../../utils/logger')('auth-password-reset') + +// Same generic answer whether or not the address matched — never leaks existence. +const GENERIC_OK = { message: 'If an account exists for that email, a reset link has been sent.' } + +function baseUrl() { + return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '') +} + +function resetUrl(token) { + return `${baseUrl()}/account/reset/${token}` +} + +// POST /auth/password/forgot — request a reset. Always 200 with GENERIC_OK. +async function requestReset(req, res) { + const email = String(req.body.email || '').trim() + try { + // Bad/empty email: answer identically so probing the shape reveals nothing. + if (email) { + const accounts = await users.getActiveByEmail(email) + for (const account of accounts) { + try { + const { token } = await passwordResets.create({ userId: account.id, requestedIp: req.ip }) + const result = await mailer.sendPasswordReset({ + to: account.email, + resetUrl: resetUrl(token), + username: account.username, + }) + if (!result.sent) { + log.warn('password reset email not sent (mail not configured)', { userId: account.id }) + } + } catch (err) { + // A send failure for one account must not abort the others, nor change + // the generic response. The pending token simply expires unused. + log.error('password reset send error', err) + } + } + await activity.log({ req, action: 'account.password.reset.request', detail: { email, matched: accounts.length } }) + log.info('password reset requested', { email, matched: accounts.length, ip: req.ip }) + } + return res.json(GENERIC_OK) + } catch (err) { + log.error('requestReset', err) + // Still generic — don't turn an internal error into an enumeration oracle. + return res.json(GENERIC_OK) + } +} + +// GET /auth/password/reset/:token — validate a link so the form can render. 404 +// for anything not currently usable (never distinguishes expired/used/never-was). +async function lookupReset(req, res) { + try { + const row = await passwordResets.findValidByToken(req.params.token) + if (!row) return res.status(404).json({ message: 'This reset link is invalid or has expired.' }) + // Surface only the target username (nice for the form); never the email/token. + const user = await users.getById(row.user_id) + if (!user) return res.status(404).json({ message: 'This reset link is invalid or has expired.' }) + return res.json({ username: user.username }) + } catch (err) { + log.error('lookupReset', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// POST /auth/password/reset/:token — set the new password. Consumes the token +// atomically (double-use safe), rotates the hash, and revokes every session. +async function confirmReset(req, res) { + try { + const row = await passwordResets.findValidByToken(req.params.token) + if (!row) return res.status(404).json({ message: 'This reset link is invalid or has expired.' }) + + // Consume first: if we lost a double-submit race, stop before touching the + // password so a spent link can't set a password twice. + const won = await passwordResets.consume(row.id) + if (!won) return res.status(404).json({ message: 'This reset link has already been used.' }) + + // Rotate the hash. users.update bumps tokens_valid_after, revoking every web + // session issued before now ("reset password → sign out everywhere"). + await users.update(row.user_id, { password: req.body.password }) + // Web sessions are covered by the cutoff bump; mobile bearer sessions live in + // their own table and must be revoked explicitly. + await mobileSessions.revokeAllForUser(row.user_id) + // Retire any other outstanding links for this user (e.g. duplicate requests). + await passwordResets.invalidatePendingForUser(row.user_id) + + await activity.log({ req, userId: row.user_id, action: 'account.password.reset.complete' }) + log.info('password reset completed', { userId: row.user_id, ip: req.ip }) + // No auto-login: the user signs in fresh, so a 2FA account still passes TOTP. + return res.json({ ok: true, message: 'Your password has been reset. You can sign in now.' }) + } catch (err) { + log.error('confirmReset', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { requestReset, lookupReset, confirmReset } diff --git a/server/src/utils/mailer.js b/server/src/utils/mailer.js index 6d426df..781a227 100644 --- a/server/src/utils/mailer.js +++ b/server/src/utils/mailer.js @@ -155,4 +155,36 @@ async function sendInvite({ to, acceptUrl, role, invitedByName }) { } } -module.exports = { isConfigured, sendContactMessage, sendTest, sendInvite } +/** + * Send a password-reset link. `to` is the account's email, `resetUrl` the tokened + * reset link, `username` names which account it's for (email is non-unique, so one + * address may receive a link per account). If email is not configured, returns + * { sent: false, reason: 'NOT_CONFIGURED' } — the caller still returns a generic + * success to avoid leaking whether the address exists. Throws only on a send failure. + */ +async function sendPasswordReset({ to, resetUrl, username }) { + const built = await buildTransport() + if (!built) return { sent: false, reason: 'NOT_CONFIGURED' } + const { transport, config } = built + const forWhom = username ? ` for the account “${username}”` : '' + try { + await transport.sendMail({ + from: fromHeader(config), + to, + subject: `Reset your ${brand.name} password`, + text: + `We received a request to reset the password${forWhom} at ${brand.name}.\n\n` + + `Choose a new password here:\n${resetUrl}\n\n` + + `This link is single-use and expires in about an hour. If you didn't request ` + + `this, you can safely ignore this email — your password won't change.`, + }) + await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Password reset send OK', lastVerifiedAt: new Date() }) + return { sent: true } + } catch (err) { + log.error('password reset send failed', err) + await emailConfig.recordStatus({ status: 'error', statusDetail: err.message }) + throw err + } +} + +module.exports = { isConfigured, sendContactMessage, sendTest, sendInvite, sendPasswordReset } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index c8bb32a..9e7b15a 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -446,6 +446,200 @@ "requestBody": {} } }, + "/api/v1/auth/password/forgot": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Request a password-reset link by email", + "description": "Emails a single-use, ~1h reset link to every active account on the address. Always returns the same generic 200 whether or not the email matches (no account enumeration). Email is non-unique, so multiple accounts may each receive a link naming their username. Rate limited per IP.", + "responses": { + "200": { + "description": "Generic acknowledgement (sent if the account exists)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "429": { + "description": "Too many requests", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "format": "email" + } + } + } + } + } + } + } + }, + "/api/v1/auth/password/reset/{token}": { + "get": { + "tags": [ + "Auth" + ], + "summary": "Validate a password-reset link", + "description": "Returns the target username for a valid, pending, unexpired reset link so the reset form can render. 404 for anything not currently usable (never distinguishes expired from used from never-existed).", + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Reset link is valid", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Invalid or expired reset link", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + }, + "post": { + "tags": [ + "Auth" + ], + "summary": "Set a new password from a reset link", + "description": "Consumes the single-use link and sets the new password. Rotates the hash and revokes every existing session (web + mobile). Does NOT sign the user in — they log in fresh afterwards (so a 2FA account still passes TOTP). Rate limited per IP.", + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Password changed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "404": { + "description": "Invalid, expired, or already-used reset link", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many attempts", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "password" + ], + "properties": { + "password": { + "type": "string", + "minLength": 8, + "maxLength": 64 + } + } + } + } + } + } + } + }, "/api/v1/auth/logout": { "post": { "tags": [ @@ -1535,7 +1729,7 @@ "tags": [ "Public · Shard" ], - "summary": "Staff online now (linked staff accounts; name + serial + map only)", + "summary": "Staff online now (linked staff accounts; location is admin/moderator-only)", "description": "", "responses": { "200": { diff --git a/server/test/passwordResets.test.js b/server/test/passwordResets.test.js new file mode 100644 index 0000000..96ad374 --- /dev/null +++ b/server/test/passwordResets.test.js @@ -0,0 +1,82 @@ +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +// Exercise password-reset create/lookup/single-use consume against an in-memory +// fake by monkeypatching the shared db module the model require()s. No DB. +const db = require('../src/model/passwordResets/passwordResets.db') +const passwordResets = require('../src/model/passwordResets/passwordResets.model') + +let rows +let nextId +const saved = {} + +beforeEach(() => { + rows = [] + nextId = 1 + for (const k of ['insert', 'getById', 'findByTokenHash', 'markUsed', 'invalidatePendingForUser']) saved[k] = db[k] + db.insert = async ({ tokenHash, userId, requestedIp, expiresAt }) => { + const id = nextId++ + rows.push({ id, token_hash: tokenHash, user_id: userId, status: 'pending', requested_ip: requestedIp ?? null, expires_at: expiresAt, created_at: new Date(), used_at: null }) + return id + } + db.getById = async (id) => rows.find((r) => r.id === id) || null + db.findByTokenHash = async (h) => rows.find((r) => r.token_hash === h) || null + db.markUsed = async (id) => { + const row = rows.find((r) => r.id === id && r.status === 'pending') + if (!row) return 0 + row.status = 'used' + row.used_at = new Date() + return 1 + } + db.invalidatePendingForUser = async (userId) => { + let n = 0 + for (const r of rows) if (r.user_id === userId && r.status === 'pending') { r.status = 'used'; n++ } + return n + } +}) + +afterEach(() => { + for (const k of Object.keys(saved)) db[k] = saved[k] +}) + +test('create stores only the token hash, never the plaintext token', async () => { + const { token } = await passwordResets.create({ userId: 7, requestedIp: '1.2.3.4' }) + assert.ok(token && token.length >= 20) + assert.equal(rows[0].token_hash, passwordResets.hashToken(token)) + assert.notEqual(rows[0].token_hash, token) // hash, not the raw token + assert.equal(rows[0].user_id, 7) + assert.equal(rows[0].status, 'pending') +}) + +test('findValidByToken resolves a pending token and rejects a wrong one', async () => { + const { token } = await passwordResets.create({ userId: 7 }) + const row = await passwordResets.findValidByToken(token) + assert.ok(row) + assert.equal(row.user_id, 7) + assert.equal(await passwordResets.findValidByToken('not-a-real-token'), null) + assert.equal(await passwordResets.findValidByToken(''), null) +}) + +test('consume is single-use — the second consume loses the race', async () => { + const { token } = await passwordResets.create({ userId: 7 }) + const row = await passwordResets.findValidByToken(token) + assert.equal(await passwordResets.consume(row.id), true) + assert.equal(await passwordResets.consume(row.id), false) // already used + assert.equal(await passwordResets.findValidByToken(token), null) // no longer pending +}) + +test('an expired reset is not valid (exercises the expiry branch, not a bad token)', async () => { + const { token } = await passwordResets.create({ userId: 7, ttlMinutes: -1 }) + assert.ok(rows[0] && rows[0].status === 'pending') // token correct, row pending + assert.equal(await passwordResets.findValidByToken(token), null) // only expiry rejects it +}) + +test('invalidatePendingForUser retires every outstanding link for a user', async () => { + const a = await passwordResets.create({ userId: 7 }) + const b = await passwordResets.create({ userId: 7 }) + await passwordResets.create({ userId: 99 }) // a different user's link is untouched + await passwordResets.invalidatePendingForUser(7) + assert.equal(await passwordResets.findValidByToken(a.token), null) + assert.equal(await passwordResets.findValidByToken(b.token), null) + assert.equal(rows.filter((r) => r.status === 'pending' && r.user_id === 99).length, 1) +})