Files
website/server/src/router/v1/auth/passwordReset.controller.js
wtclaude 10aed49bb6
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
feat(auth): self-service password reset (backend + web)
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
2026-07-19 03:57:13 -05:00

120 lines
5.9 KiB
JavaScript

// ── 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 }