const bcrypt = require('bcryptjs'); const { all, audit, now, run } = require('../db'); // Configurable password & lockout policy for locally managed users. The policy lives as // `password_*` rows in app_settings (no schema churn to tune rules), is read back through // getPolicy(), and is enforced at every password-set point plus on login. const DEFAULT_POLICY = { min_length: 12, require_upper: true, require_lower: true, require_number: true, require_symbol: true, disallow_identifiers: true, // block the user's name / email local-part inside the password history_count: 5, // disallow reuse of the last N passwords (0 = off) expiry_days: 90, // force a change after this many days (0 = never) min_age_hours: 0, // minimum age before a user may change again (0 = off) lockout_threshold: 5, // failed logins before lock (0 = off) lockout_window_minutes: 15 // how long an account stays locked }; // Each policy field plus how to coerce the stored string back to its typed value. const NUMERIC_KEYS = ['min_length', 'history_count', 'expiry_days', 'min_age_hours', 'lockout_threshold', 'lockout_window_minutes']; const BOOLEAN_KEYS = ['require_upper', 'require_lower', 'require_number', 'require_symbol', 'disallow_identifiers']; function clampInt(value, fallback, min, max) { const n = Number(value); if (!Number.isFinite(n)) return fallback; return Math.min(max, Math.max(min, Math.round(n))); } function toBool(value, fallback) { if (value === undefined || value === null || value === '') return fallback; if (typeof value === 'boolean') return value; return value === '1' || value === 'true' || value === 1; } // Read the saved policy, falling back to defaults for any unset key. function getPolicy() { const rows = all("SELECT key, value FROM app_settings WHERE key LIKE 'password_%'"); const stored = Object.fromEntries(rows.map((r) => [r.key.replace(/^password_/, ''), r.value])); const policy = { ...DEFAULT_POLICY }; for (const key of NUMERIC_KEYS) { if (stored[key] !== undefined) policy[key] = clampInt(stored[key], DEFAULT_POLICY[key], 0, 100000); } for (const key of BOOLEAN_KEYS) { if (stored[key] !== undefined) policy[key] = toBool(stored[key], DEFAULT_POLICY[key]); } // min_length has a sane floor regardless of what was stored. policy.min_length = Math.max(4, policy.min_length); return policy; } // Persist a (partial) policy. Unknown keys are ignored; values are normalized through getPolicy's coercion. function savePolicy(body, userId) { const before = getPolicy(); const incoming = body && typeof body === 'object' ? body : {}; const ts = now(); const write = (key, value) => run( 'INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at', [`password_${key}`, String(value), ts] ); for (const key of NUMERIC_KEYS) { if (incoming[key] !== undefined) write(key, clampInt(incoming[key], DEFAULT_POLICY[key], 0, 100000)); } for (const key of BOOLEAN_KEYS) { if (incoming[key] !== undefined) write(key, toBool(incoming[key], DEFAULT_POLICY[key]) ? 1 : 0); } const after = getPolicy(); audit(userId, 'update', 'password_policy', 'app', before, after); return after; } // Human-readable rules for the UI hint/preview. function describe(policy = getPolicy()) { const rules = [`At least ${policy.min_length} characters`]; if (policy.require_upper) rules.push('An uppercase letter (A–Z)'); if (policy.require_lower) rules.push('A lowercase letter (a–z)'); if (policy.require_number) rules.push('A number (0–9)'); if (policy.require_symbol) rules.push('A symbol (e.g. !@#$%)'); if (policy.disallow_identifiers) rules.push('Must not contain your name or email'); if (policy.history_count > 0) rules.push(`Cannot reuse your last ${policy.history_count} password(s)`); if (policy.expiry_days > 0) rules.push(`Expires every ${policy.expiry_days} days`); if (policy.lockout_threshold > 0) rules.push(`Locks for ${policy.lockout_window_minutes} min after ${policy.lockout_threshold} failed sign-ins`); return rules; } const SYMBOL_RE = /[^A-Za-z0-9]/; // Returns an array of human-readable failure messages ([] when the password satisfies the policy). // `user` (optional) enables identifier and history checks; pass { id, name, email }. function checkPassword(password, user = null, policy = getPolicy()) { const failures = []; const value = String(password || ''); if (value.length < policy.min_length) failures.push(`Use at least ${policy.min_length} characters`); if (policy.require_upper && !/[A-Z]/.test(value)) failures.push('Add an uppercase letter'); if (policy.require_lower && !/[a-z]/.test(value)) failures.push('Add a lowercase letter'); if (policy.require_number && !/[0-9]/.test(value)) failures.push('Add a number'); if (policy.require_symbol && !SYMBOL_RE.test(value)) failures.push('Add a symbol'); if (policy.disallow_identifiers && user) { const haystack = value.toLowerCase(); const needles = []; if (user.name) needles.push(...String(user.name).toLowerCase().split(/\s+/).filter((p) => p.length >= 3)); if (user.email) { const local = String(user.email).toLowerCase().split('@')[0]; if (local && local.length >= 3) needles.push(local); } if (needles.some((n) => haystack.includes(n))) failures.push('Must not contain your name or email'); } if (policy.history_count > 0 && user && user.id) { const recent = all( 'SELECT password_hash FROM password_history WHERE user_id = ? ORDER BY id DESC LIMIT ?', [user.id, policy.history_count] ); if (recent.some((row) => bcrypt.compareSync(value, row.password_hash))) { failures.push(`Choose a password you have not used in your last ${policy.history_count}`); } } return failures; } // Throwing wrapper used by routes: 400s with the joined failure list. function validatePassword(password, user = null) { const failures = checkPassword(password, user, getPolicy()); if (failures.length) throw Object.assign(new Error(failures.join('. ')), { status: 400 }); } // Record a newly set hash and prune the per-user history to the configured depth. function recordPassword(userId, hash) { run('INSERT INTO password_history (user_id, password_hash, created_at) VALUES (?, ?, ?)', [userId, hash, now()]); const keep = Math.max(getPolicy().history_count, 1); run( `DELETE FROM password_history WHERE user_id = ? AND id NOT IN (SELECT id FROM password_history WHERE user_id = ? ORDER BY id DESC LIMIT ?)`, [userId, userId, keep] ); } // ---- Account lockout --------------------------------------------------------- function isLocked(user) { return Boolean(user && user.locked_until && new Date(user.locked_until).getTime() > Date.now()); } // After a failed login: bump the counter and lock the account if the threshold is reached. // Returns { locked, lockedUntil } so the caller can shape the response. function registerFailure(user) { const policy = getPolicy(); const attempts = (user.failed_attempts || 0) + 1; let lockedUntil = null; if (policy.lockout_threshold > 0 && attempts >= policy.lockout_threshold) { lockedUntil = new Date(Date.now() + policy.lockout_window_minutes * 60000).toISOString(); } run('UPDATE users SET failed_attempts = ?, locked_until = ?, updated_at = ? WHERE id = ?', [ attempts, lockedUntil, now(), user.id ]); if (lockedUntil) audit(user.id, 'account_locked', 'user', user.id, null, { until: lockedUntil, attempts }); return { locked: Boolean(lockedUntil), lockedUntil, attempts }; } // After a successful login: clear the lockout counters and stamp last_login_at. function registerSuccess(user) { run('UPDATE users SET failed_attempts = 0, locked_until = NULL, last_login_at = ?, updated_at = ? WHERE id = ?', [ now(), now(), user.id ]); } // Admin action: clear a lock so the user can sign in again immediately. function unlock(userId, actorId) { run('UPDATE users SET failed_attempts = 0, locked_until = NULL, updated_at = ? WHERE id = ?', [now(), userId]); audit(actorId, 'account_unlocked', 'user', userId, null, null); } // Has this user's password aged past the expiry window? function passwordExpired(user, policy = getPolicy()) { if (!policy.expiry_days || !user || !user.password_changed_at) return false; const age = Date.now() - new Date(user.password_changed_at).getTime(); return age > policy.expiry_days * 86400000; } // Does the policy forbid changing again this soon? Returns minutes remaining (0 when allowed). function minAgeRemainingMinutes(user, policy = getPolicy()) { if (!policy.min_age_hours || !user || !user.password_changed_at) return 0; const elapsedMs = Date.now() - new Date(user.password_changed_at).getTime(); const remainingMs = policy.min_age_hours * 3600000 - elapsedMs; return remainingMs > 0 ? Math.ceil(remainingMs / 60000) : 0; } // Convenience used by login to decide whether to force a change step. function mustChangePassword(user) { return Boolean(user.must_change_password) || passwordExpired(user); } module.exports = { DEFAULT_POLICY, checkPassword, describe, getPolicy, isLocked, minAgeRemainingMinutes, mustChangePassword, passwordExpired, recordPassword, registerFailure, registerSuccess, savePolicy, unlock, validatePassword };