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