Files
MixedAssets/server/services/passwordPolicy.js

213 lines
9.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
};