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

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
This commit is contained in:
2026-07-19 03:57:13 -05:00
parent 9ac1f35fa0
commit 10aed49bb6
16 changed files with 851 additions and 9 deletions

View File

@@ -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,
}

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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,

View File

@@ -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,

View File

@@ -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']

View File

@@ -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 }

View File

@@ -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 }