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
135 lines
4.0 KiB
JavaScript
135 lines
4.0 KiB
JavaScript
const bcrypt = require('bcryptjs')
|
|
const usersDb = require('./users.db')
|
|
|
|
const SALT_ROUNDS = 10
|
|
|
|
// Strip secrets (password hash, TOTP secret) before sending a user anywhere.
|
|
function sanitize(user) {
|
|
if (!user) return null
|
|
const { password_hash, totp_secret, ...safe } = user
|
|
return safe
|
|
}
|
|
|
|
// password may be omitted/null — an SSO-provisioned player has no password until
|
|
// they set one (a null hash makes password login impossible, see validatePassword).
|
|
async function createUser({ username, password, role = 'admin', email = null, status = 'active', emailVerified = false }) {
|
|
const passwordHash = password ? await bcrypt.hash(password, SALT_ROUNDS) : null
|
|
const id = await usersDb.insertUser({ username, passwordHash, role, email, status, emailVerified })
|
|
return sanitize(await usersDb.findById(id))
|
|
}
|
|
|
|
// True when a DB error is the unique-index violation on username (the atomic
|
|
// backstop for the uniqueness race). Callers translate this into a 409 rather
|
|
// than doing a check-then-write.
|
|
function isDuplicateUsername(err) {
|
|
return Boolean(err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062))
|
|
}
|
|
|
|
// Returns the raw row (incl. hash) — used by login only.
|
|
async function getRawByUsername(username) {
|
|
return usersDb.findByUsername(username)
|
|
}
|
|
|
|
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) {
|
|
return usersDb.findById(id)
|
|
}
|
|
|
|
async function setTotpSecret(id, secret) {
|
|
return usersDb.setTotpSecret(id, secret)
|
|
}
|
|
|
|
async function enableTotp(id) {
|
|
return usersDb.enableTotp(id)
|
|
}
|
|
|
|
async function disableTotp(id) {
|
|
return usersDb.disableTotp(id)
|
|
}
|
|
|
|
async function validatePassword(user, password) {
|
|
if (!user || !user.password_hash) return false
|
|
return bcrypt.compare(password, user.password_hash)
|
|
}
|
|
|
|
async function list() {
|
|
return usersDb.listUsers()
|
|
}
|
|
|
|
async function update(id, { username, password, role, email, status, emailVerified }) {
|
|
const fields = {}
|
|
if (username !== undefined) fields.username = username
|
|
if (role !== undefined) fields.role = role
|
|
if (email !== undefined) fields.email = email
|
|
if (status !== undefined) fields.status = status
|
|
if (emailVerified !== undefined) fields.email_verified = emailVerified ? 1 : 0
|
|
if (password) fields.password_hash = await bcrypt.hash(password, SALT_ROUNDS)
|
|
await usersDb.updateUser(id, fields)
|
|
// A password change must revoke existing sessions ("change password to log
|
|
// everyone out"), so bump the cutoff whenever the hash was rotated.
|
|
if (password) await usersDb.bumpTokensValidAfter(id)
|
|
return getById(id)
|
|
}
|
|
|
|
// Invalidate every session token this user currently holds ("log out everywhere")
|
|
// by advancing their tokens_valid_after cutoff to now.
|
|
async function invalidateSessions(id) {
|
|
return usersDb.bumpTokensValidAfter(id)
|
|
}
|
|
|
|
// Set the session cutoff to an explicit instant. Used by the self password-change
|
|
// flow to keep the caller's freshly re-issued session alive (see users.db).
|
|
async function setSessionCutoff(id, when) {
|
|
return usersDb.setTokensValidAfter(id, when)
|
|
}
|
|
|
|
async function remove(id) {
|
|
return usersDb.deleteUser(id)
|
|
}
|
|
|
|
async function count() {
|
|
return usersDb.countUsers()
|
|
}
|
|
|
|
async function countAdmins() {
|
|
return usersDb.countAdmins()
|
|
}
|
|
|
|
async function recordLogin(id, ip = null) {
|
|
return usersDb.touchLastLogin(id, ip)
|
|
}
|
|
|
|
module.exports = {
|
|
createUser,
|
|
isDuplicateUsername,
|
|
getRawByUsername,
|
|
getById,
|
|
getActiveByEmail,
|
|
getRawById,
|
|
validatePassword,
|
|
list,
|
|
update,
|
|
invalidateSessions,
|
|
setSessionCutoff,
|
|
remove,
|
|
count,
|
|
countAdmins,
|
|
recordLogin,
|
|
setTotpSecret,
|
|
enableTotp,
|
|
disableTotp,
|
|
}
|