auth-password-policy.ts297 lines · main
| 1 | /** |
| 2 | * Password policy — Gap Fix #13 / Sprint S3. |
| 3 | * |
| 4 | * Per-tenant complexity + expiry + reuse rules. |
| 5 | * Enforced on sign-up / reset / change (bridge) and on session create (expiry). |
| 6 | */ |
| 7 | |
| 8 | import { createHash } from 'node:crypto'; |
| 9 | |
| 10 | import { ValidationError } from '@briven/shared'; |
| 11 | |
| 12 | import { runInProjectDatabase } from '../db/data-plane.js'; |
| 13 | |
| 14 | export interface PasswordPolicy { |
| 15 | minLength: number; |
| 16 | requireUppercase: boolean; |
| 17 | requireLowercase: boolean; |
| 18 | requireNumber: boolean; |
| 19 | requireSpecial: boolean; |
| 20 | /** When set, passwords older than this many days block new sessions. */ |
| 21 | maxAgeDays: number | null; |
| 22 | /** How many previous passwords cannot be reused (0 = off). */ |
| 23 | preventReuse: number; |
| 24 | } |
| 25 | |
| 26 | export const DEFAULT_PASSWORD_POLICY: PasswordPolicy = { |
| 27 | minLength: 8, |
| 28 | requireUppercase: false, |
| 29 | requireLowercase: false, |
| 30 | requireNumber: false, |
| 31 | requireSpecial: false, |
| 32 | maxAgeDays: null, |
| 33 | preventReuse: 0, |
| 34 | }; |
| 35 | |
| 36 | /** Digest for history rows only — not used for authentication. */ |
| 37 | export function passwordHistoryDigest(password: string): string { |
| 38 | return createHash('sha256').update(password, 'utf8').digest('hex'); |
| 39 | } |
| 40 | |
| 41 | export function validatePassword(password: string, policy: PasswordPolicy): void { |
| 42 | if (password.length < policy.minLength) { |
| 43 | throw new ValidationError(`password must be at least ${policy.minLength} characters`); |
| 44 | } |
| 45 | if (policy.requireUppercase && !/[A-Z]/.test(password)) { |
| 46 | throw new ValidationError('password must contain an uppercase letter'); |
| 47 | } |
| 48 | if (policy.requireLowercase && !/[a-z]/.test(password)) { |
| 49 | throw new ValidationError('password must contain a lowercase letter'); |
| 50 | } |
| 51 | if (policy.requireNumber && !/[0-9]/.test(password)) { |
| 52 | throw new ValidationError('password must contain a number'); |
| 53 | } |
| 54 | if (policy.requireSpecial && !/[^A-Za-z0-9]/.test(password)) { |
| 55 | throw new ValidationError('password must contain a special character'); |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | export async function getPasswordPolicy(projectId: string): Promise<PasswordPolicy> { |
| 60 | const rows = await runInProjectDatabase< |
| 61 | Array<{ |
| 62 | min_length: number; |
| 63 | require_uppercase: boolean; |
| 64 | require_lowercase: boolean; |
| 65 | require_number: boolean; |
| 66 | require_special: boolean; |
| 67 | max_age_days: number | null; |
| 68 | prevent_reuse: number; |
| 69 | }> |
| 70 | >(projectId, async (tx) => |
| 71 | tx.unsafe( |
| 72 | `SELECT min_length, require_uppercase, require_lowercase, require_number, require_special, max_age_days, prevent_reuse |
| 73 | FROM "_briven_auth_password_policy" |
| 74 | LIMIT 1`, |
| 75 | ) as never, |
| 76 | ); |
| 77 | const row = rows[0]; |
| 78 | if (!row) return { ...DEFAULT_PASSWORD_POLICY }; |
| 79 | return { |
| 80 | minLength: Number(row.min_length), |
| 81 | requireUppercase: Boolean(row.require_uppercase), |
| 82 | requireLowercase: Boolean(row.require_lowercase), |
| 83 | requireNumber: Boolean(row.require_number), |
| 84 | requireSpecial: Boolean(row.require_special), |
| 85 | maxAgeDays: row.max_age_days == null ? null : Number(row.max_age_days), |
| 86 | preventReuse: Number(row.prevent_reuse), |
| 87 | }; |
| 88 | } |
| 89 | |
| 90 | export async function setPasswordPolicy( |
| 91 | projectId: string, |
| 92 | patch: Partial<PasswordPolicy>, |
| 93 | ): Promise<PasswordPolicy> { |
| 94 | const current = await getPasswordPolicy(projectId); |
| 95 | const policy: PasswordPolicy = { |
| 96 | ...current, |
| 97 | ...patch, |
| 98 | // Explicit null maxAgeDays must clear the field. |
| 99 | maxAgeDays: patch.maxAgeDays === undefined ? current.maxAgeDays : patch.maxAgeDays, |
| 100 | }; |
| 101 | if (policy.minLength < 6) { |
| 102 | throw new ValidationError('min_length must be at least 6'); |
| 103 | } |
| 104 | if (policy.maxAgeDays !== null && policy.maxAgeDays < 1) { |
| 105 | throw new ValidationError('max_age_days must be at least 1'); |
| 106 | } |
| 107 | if (policy.preventReuse < 0) { |
| 108 | throw new ValidationError('prevent_reuse must be >= 0'); |
| 109 | } |
| 110 | |
| 111 | await runInProjectDatabase(projectId, async (tx) => { |
| 112 | const existing = await tx.unsafe( |
| 113 | `SELECT 1 FROM "_briven_auth_password_policy" LIMIT 1`, |
| 114 | ); |
| 115 | if ((existing as unknown[]).length > 0) { |
| 116 | await tx.unsafe( |
| 117 | `UPDATE "_briven_auth_password_policy" |
| 118 | SET min_length = $1, require_uppercase = $2, require_lowercase = $3, |
| 119 | require_number = $4, require_special = $5, max_age_days = $6, |
| 120 | prevent_reuse = $7, updated_at = now()`, |
| 121 | [ |
| 122 | policy.minLength, |
| 123 | policy.requireUppercase, |
| 124 | policy.requireLowercase, |
| 125 | policy.requireNumber, |
| 126 | policy.requireSpecial, |
| 127 | policy.maxAgeDays, |
| 128 | policy.preventReuse, |
| 129 | ] as never, |
| 130 | ); |
| 131 | } else { |
| 132 | await tx.unsafe( |
| 133 | `INSERT INTO "_briven_auth_password_policy" |
| 134 | (id, min_length, require_uppercase, require_lowercase, require_number, require_special, max_age_days, prevent_reuse, created_at, updated_at) |
| 135 | VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, now(), now())`, |
| 136 | [ |
| 137 | policy.minLength, |
| 138 | policy.requireUppercase, |
| 139 | policy.requireLowercase, |
| 140 | policy.requireNumber, |
| 141 | policy.requireSpecial, |
| 142 | policy.maxAgeDays, |
| 143 | policy.preventReuse, |
| 144 | ] as never, |
| 145 | ); |
| 146 | } |
| 147 | }); |
| 148 | |
| 149 | return policy; |
| 150 | } |
| 151 | |
| 152 | /** |
| 153 | * Reject if `password` matches one of the last N history digests. |
| 154 | * Call BEFORE accepting a password change. |
| 155 | */ |
| 156 | export async function assertPasswordNotReused( |
| 157 | projectId: string, |
| 158 | userId: string, |
| 159 | password: string, |
| 160 | policy: PasswordPolicy = DEFAULT_PASSWORD_POLICY, |
| 161 | ): Promise<void> { |
| 162 | if (policy.preventReuse <= 0) return; |
| 163 | const digest = passwordHistoryDigest(password); |
| 164 | const rows = await runInProjectDatabase<Array<{ password_hash: string }>>( |
| 165 | projectId, |
| 166 | async (tx) => |
| 167 | tx.unsafe( |
| 168 | `SELECT password_hash FROM "_briven_auth_password_history" |
| 169 | WHERE user_id = $1 |
| 170 | ORDER BY created_at DESC |
| 171 | LIMIT $2`, |
| 172 | [userId, policy.preventReuse] as never, |
| 173 | ) as never, |
| 174 | ); |
| 175 | if (rows.some((r) => r.password_hash === digest)) { |
| 176 | throw new ValidationError( |
| 177 | `password was used recently — choose a different password (last ${policy.preventReuse} not allowed)`, |
| 178 | ); |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | /** Record a password change for reuse checks + age tracking. */ |
| 183 | export async function recordPasswordHistory( |
| 184 | projectId: string, |
| 185 | userId: string, |
| 186 | password: string, |
| 187 | ): Promise<void> { |
| 188 | const digest = passwordHistoryDigest(password); |
| 189 | await runInProjectDatabase(projectId, async (tx) => { |
| 190 | await tx.unsafe( |
| 191 | `INSERT INTO "_briven_auth_password_history" (id, user_id, password_hash, created_at) |
| 192 | VALUES (gen_random_uuid()::text, $1, $2, now())`, |
| 193 | [userId, digest] as never, |
| 194 | ); |
| 195 | // Cap history growth (keep 20). |
| 196 | await tx.unsafe( |
| 197 | `DELETE FROM "_briven_auth_password_history" |
| 198 | WHERE user_id = $1 |
| 199 | AND id NOT IN ( |
| 200 | SELECT id FROM "_briven_auth_password_history" |
| 201 | WHERE user_id = $1 |
| 202 | ORDER BY created_at DESC |
| 203 | LIMIT 20 |
| 204 | )`, |
| 205 | [userId] as never, |
| 206 | ); |
| 207 | // Clear admin force-reset if present. |
| 208 | await tx.unsafe( |
| 209 | `DELETE FROM "_briven_auth_password_force_reset" WHERE user_id = $1`, |
| 210 | [userId] as never, |
| 211 | ); |
| 212 | }); |
| 213 | } |
| 214 | |
| 215 | /** |
| 216 | * True when the user must change password before getting a session: |
| 217 | * force-reset flag OR password older than maxAgeDays. |
| 218 | */ |
| 219 | export async function mustChangePassword( |
| 220 | projectId: string, |
| 221 | userId: string, |
| 222 | ): Promise<{ required: boolean; reason?: string }> { |
| 223 | const policy = await getPasswordPolicy(projectId); |
| 224 | |
| 225 | const forced = await runInProjectDatabase<Array<{ user_id: string }>>( |
| 226 | projectId, |
| 227 | async (tx) => |
| 228 | tx.unsafe( |
| 229 | `SELECT user_id FROM "_briven_auth_password_force_reset" WHERE user_id = $1 LIMIT 1`, |
| 230 | [userId] as never, |
| 231 | ) as never, |
| 232 | ); |
| 233 | if (forced.length > 0) { |
| 234 | return { required: true, reason: 'password change required by administrator' }; |
| 235 | } |
| 236 | |
| 237 | if (policy.maxAgeDays == null || policy.maxAgeDays <= 0) { |
| 238 | return { required: false }; |
| 239 | } |
| 240 | |
| 241 | // Prefer last history row; fall back to credential account updated_at. |
| 242 | const ages = await runInProjectDatabase<Array<{ changed_at: Date | string | null }>>( |
| 243 | projectId, |
| 244 | async (tx) => |
| 245 | tx.unsafe( |
| 246 | `SELECT COALESCE( |
| 247 | (SELECT created_at FROM "_briven_auth_password_history" |
| 248 | WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1), |
| 249 | (SELECT updated_at FROM "_briven_auth_accounts" |
| 250 | WHERE user_id = $1 AND provider_id = 'credential' LIMIT 1), |
| 251 | (SELECT created_at FROM "_briven_auth_users" WHERE id = $1 LIMIT 1) |
| 252 | ) AS changed_at`, |
| 253 | [userId] as never, |
| 254 | ) as never, |
| 255 | ); |
| 256 | const raw = ages[0]?.changed_at; |
| 257 | if (!raw) return { required: false }; |
| 258 | const changedAt = raw instanceof Date ? raw : new Date(raw); |
| 259 | if (Number.isNaN(changedAt.getTime())) return { required: false }; |
| 260 | const ageMs = Date.now() - changedAt.getTime(); |
| 261 | const maxMs = policy.maxAgeDays * 86_400_000; |
| 262 | if (ageMs > maxMs) { |
| 263 | return { |
| 264 | required: true, |
| 265 | reason: `password expired after ${policy.maxAgeDays} days — please reset your password`, |
| 266 | }; |
| 267 | } |
| 268 | return { required: false }; |
| 269 | } |
| 270 | |
| 271 | /** Admin: require password change on next sign-in. */ |
| 272 | export async function forcePasswordReset( |
| 273 | projectId: string, |
| 274 | userId: string, |
| 275 | reason?: string, |
| 276 | ): Promise<void> { |
| 277 | await runInProjectDatabase(projectId, async (tx) => { |
| 278 | await tx.unsafe( |
| 279 | `INSERT INTO "_briven_auth_password_force_reset" (user_id, reason, forced_at) |
| 280 | VALUES ($1, $2, now()) |
| 281 | ON CONFLICT (user_id) DO UPDATE SET reason = $2, forced_at = now()`, |
| 282 | [userId, reason ?? null] as never, |
| 283 | ); |
| 284 | }); |
| 285 | } |
| 286 | |
| 287 | export async function clearForcePasswordReset( |
| 288 | projectId: string, |
| 289 | userId: string, |
| 290 | ): Promise<void> { |
| 291 | await runInProjectDatabase(projectId, async (tx) => { |
| 292 | await tx.unsafe( |
| 293 | `DELETE FROM "_briven_auth_password_force_reset" WHERE user_id = $1`, |
| 294 | [userId] as never, |
| 295 | ); |
| 296 | }); |
| 297 | } |