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
This commit is contained in:
46
server/src/model/passwordResets/passwordResets.db.js
Normal file
46
server/src/model/passwordResets/passwordResets.db.js
Normal 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 }
|
||||
47
server/src/model/passwordResets/passwordResets.model.js
Normal file
47
server/src/model/passwordResets/passwordResets.model.js
Normal 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 }
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user