Password Policy/App rename/PM Scheduling Improvements

This commit is contained in:
2026-06-06 02:32:47 -05:00
parent dfd999b6a9
commit f024286b5e
59 changed files with 3814 additions and 299 deletions

View File

@@ -0,0 +1,212 @@
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 (AZ)');
if (policy.require_lower) rules.push('A lowercase letter (az)');
if (policy.require_number) rules.push('A number (09)');
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
};