Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/services/auth/password-policy.server.ts
T

225 lines
8.1 KiB
TypeScript

// Central password validation. Every password-write endpoint MUST go through
// validatePassword(); inline length / character-class checks are forbidden.
// Configuration is env-driven so operators can tighten the policy without a
// code deploy.
//
// Supersedes the earlier prototype (validatePasswordPolicy / isPasswordPwned).
// Those were never wired up to any caller; the new API is the canonical one.
import { createHash } from "node:crypto";
const HIBP_API_BASE = "https://api.pwnedpasswords.com/range/";
const HIBP_TIMEOUT_MS = 3000;
const ABSOLUTE_MAX_LENGTH = 1024;
export type PasswordPolicyConfig = {
minLength: number;
requireUpper: boolean;
requireLower: boolean;
requireDigit: boolean;
requireSpecial: boolean;
hibpCheck: boolean;
hibpMaxOccurrences: number;
};
export type PasswordPolicyContext = {
email?: string;
};
/** Bounded i18n keys for each failure mode. */
export type PasswordPolicyErrorCode =
| "auth.password.errors.tooShort"
| "auth.password.errors.tooLong"
| "auth.password.errors.missingUpper"
| "auth.password.errors.missingLower"
| "auth.password.errors.missingDigit"
| "auth.password.errors.missingSpecial"
| "auth.password.errors.containsEmail"
| "auth.password.errors.pwned"
| "auth.password.errors.empty";
export type PasswordPolicyError = {
code: PasswordPolicyErrorCode;
/** Non-secret args for the i18n template (e.g. minLength). */
args?: Record<string, string | number>;
};
export type PasswordPolicyValidation = {
valid: boolean;
errors: PasswordPolicyError[];
/**
* True when the HIBP check was skipped because the API call failed. Route
* code may want to log this so operators can monitor the bypass rate.
*/
hibpBypassed?: boolean;
};
const SPECIAL_CHAR_RE = /[^A-Za-z0-9]/;
export function loadPasswordPolicy(env: Record<string, string | undefined> = process.env): PasswordPolicyConfig {
return {
minLength: parseIntWithDefault(env.AUTH_PASSWORD_MIN_LENGTH, 12),
requireUpper: parseBool(env.AUTH_PASSWORD_REQUIRE_UPPER, true),
requireLower: parseBool(env.AUTH_PASSWORD_REQUIRE_LOWER, true),
requireDigit: parseBool(env.AUTH_PASSWORD_REQUIRE_DIGIT, true),
requireSpecial: parseBool(env.AUTH_PASSWORD_REQUIRE_SPECIAL, false),
hibpCheck: parseBool(env.AUTH_PASSWORD_HIBP_CHECK, true),
hibpMaxOccurrences: parseIntWithDefault(env.AUTH_PASSWORD_HIBP_MAX_OCCURRENCES, 0),
};
}
export type ValidatePasswordOptions = {
policy?: PasswordPolicyConfig;
/** Test seam. Defaults to globalThis.fetch. */
fetcher?: typeof fetch;
};
export async function validatePassword(
plain: string,
ctx: PasswordPolicyContext = {},
options: ValidatePasswordOptions = {}
): Promise<PasswordPolicyValidation> {
const policy = options.policy ?? loadPasswordPolicy();
const errors: PasswordPolicyError[] = [];
if (typeof plain !== "string" || plain.length === 0) {
return { valid: false, errors: [{ code: "auth.password.errors.empty" }] };
}
if (plain.length < policy.minLength) {
errors.push({ code: "auth.password.errors.tooShort", args: { minLength: policy.minLength } });
}
if (plain.length > ABSOLUTE_MAX_LENGTH) {
errors.push({ code: "auth.password.errors.tooLong", args: { maxLength: ABSOLUTE_MAX_LENGTH } });
}
if (policy.requireUpper && !/[A-Z]/.test(plain)) {
errors.push({ code: "auth.password.errors.missingUpper" });
}
if (policy.requireLower && !/[a-z]/.test(plain)) {
errors.push({ code: "auth.password.errors.missingLower" });
}
if (policy.requireDigit && !/\d/.test(plain)) {
errors.push({ code: "auth.password.errors.missingDigit" });
}
if (policy.requireSpecial && !SPECIAL_CHAR_RE.test(plain)) {
errors.push({ code: "auth.password.errors.missingSpecial" });
}
if (ctx.email && containsEmailLocalPart(plain, ctx.email)) {
errors.push({ code: "auth.password.errors.containsEmail" });
}
let hibpBypassed = false;
if (policy.hibpCheck) {
const result = await checkHibp(plain, policy.hibpMaxOccurrences, options.fetcher);
if (result === "pwned") {
errors.push({ code: "auth.password.errors.pwned" });
} else if (result === "bypassed") {
hibpBypassed = true;
}
}
return { valid: errors.length === 0, errors, ...(hibpBypassed ? { hibpBypassed } : {}) };
}
function containsEmailLocalPart(plain: string, email: string): boolean {
const local = email.split("@")[0]?.toLowerCase();
if (!local || local.length < 3) return false;
return plain.toLowerCase().includes(local);
}
async function checkHibp(plain: string, maxOccurrences: number, fetcher?: typeof fetch): Promise<"clean" | "pwned" | "bypassed"> {
const sha1 = createHash("sha1").update(plain).digest("hex").toUpperCase();
const prefix = sha1.slice(0, 5);
const suffix = sha1.slice(5);
const url = `${HIBP_API_BASE}${prefix}`;
const usedFetcher = fetcher ?? globalThis.fetch;
if (typeof usedFetcher !== "function") return "bypassed";
let response: Response;
try {
response = await withTimeout(usedFetcher(url, { headers: { "Add-Padding": "true" } }), HIBP_TIMEOUT_MS);
} catch {
return "bypassed";
}
if (!response.ok) return "bypassed";
let body: string;
try {
body = await response.text();
} catch {
return "bypassed";
}
for (const line of body.split(/\r?\n/)) {
const [candidateSuffix, countStr] = line.trim().split(":");
if (candidateSuffix?.toUpperCase() !== suffix) continue;
const count = Number(countStr);
if (!Number.isFinite(count)) continue;
if (count > maxOccurrences) return "pwned";
return "clean";
}
return "clean";
}
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error("hibp_timeout")), ms);
});
try {
return await Promise.race([promise, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
function parseIntWithDefault(raw: string | undefined, fallback: number): number {
if (!raw) return fallback;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
function parseBool(raw: string | undefined, fallback: boolean): boolean {
if (raw === undefined) return fallback;
const lower = raw.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(lower)) return true;
if (["false", "0", "no", "off"].includes(lower)) return false;
return fallback;
}
// ---------------------------------------------------------------------------
// Backwards-compatibility shims for the prototype API. Nothing in-tree calls
// these (rg -n "validatePasswordPolicy|assertPasswordPolicy|isPasswordPwned|
// MIN_PASSWORD_LENGTH" returns zero matches at the time of this rewrite), but
// they stay exported as one-liners around validatePassword so out-of-tree
// scripts that imported them don't break.
export const MIN_PASSWORD_LENGTH = 12;
export async function isPasswordPwned(password: string, fetchImpl: typeof fetch = fetch): Promise<boolean> {
const result = await checkHibp(password, 0, fetchImpl);
return result === "pwned";
}
export type PasswordPolicyIssue = "too-short" | "pwned" | "pwned-check-unavailable";
export type PasswordPolicyResult = {
ok: boolean;
issues: PasswordPolicyIssue[];
};
export async function validatePasswordPolicy(password: string, options: { fetchImpl?: typeof fetch } = {}): Promise<PasswordPolicyResult> {
const result = await validatePassword(password, {}, { fetcher: options.fetchImpl });
const issues: PasswordPolicyIssue[] = [];
if (result.errors.some((e) => e.code === "auth.password.errors.tooShort")) issues.push("too-short");
if (result.errors.some((e) => e.code === "auth.password.errors.pwned")) issues.push("pwned");
if (result.hibpBypassed) issues.push("pwned-check-unavailable");
return { ok: result.valid && !result.hibpBypassed, issues };
}
export async function assertPasswordPolicy(password: string, options: { fetchImpl?: typeof fetch } = {}) {
const result = await validatePasswordPolicy(password, options);
if (!result.ok) {
throw new Error(`Password policy failed: ${result.issues.join(", ")}`);
}
return result;
}