/** * Password security policy and account lockout helpers. * * These utilities are pure (no DB / I/O) so they can be safely used * in both server and client contexts. */ export const PASSWORD_RULES = { minLength: 8, requireUppercase: true, requireLowercase: true, requireNumber: true, requireSpecialChar: false, maxLoginAttempts: 5, lockoutDurationMinutes: 30, } as const export interface PasswordValidationResult { valid: boolean errors: string[] } /** * Validate a password against the configured policy. */ export function validatePassword(password: string): PasswordValidationResult { const errors: string[] = [] if (password.length < PASSWORD_RULES.minLength) { errors.push(`Password must be at least ${PASSWORD_RULES.minLength} characters long`) } if (PASSWORD_RULES.requireUppercase && !/[A-Z]/.test(password)) { errors.push("Password must contain at least one uppercase letter") } if (PASSWORD_RULES.requireLowercase && !/[a-z]/.test(password)) { errors.push("Password must contain at least one lowercase letter") } if (PASSWORD_RULES.requireNumber && !/[0-9]/.test(password)) { errors.push("Password must contain at least one number") } if (PASSWORD_RULES.requireSpecialChar && !/[^A-Za-z0-9]/.test(password)) { errors.push("Password must contain at least one special character") } return { valid: errors.length === 0, errors } } export type PasswordStrength = "weak" | "medium" | "strong" /** * Compute a coarse password strength label based on length and character * diversity. Useful for client-side strength indicators. */ export function getPasswordStrength(password: string): PasswordStrength { if (password.length === 0) return "weak" let score = 0 if (password.length >= 8) score++ if (password.length >= 12) score++ if (/[a-z]/.test(password)) score++ if (/[A-Z]/.test(password)) score++ if (/[0-9]/.test(password)) score++ if (/[^A-Za-z0-9]/.test(password)) score++ if (score <= 2) return "weak" if (score <= 4) return "medium" return "strong" } /** * Determine whether an account should be considered locked given its * failed-attempt count and the timestamp of the most recent failure. * * The lockout is lifted automatically once `lockoutDurationMinutes` have * elapsed since `lastFailedAt`. */ export function isAccountLocked( failedAttempts: number, lastFailedAt: Date | null ): boolean { if (failedAttempts < PASSWORD_RULES.maxLoginAttempts) return false if (!lastFailedAt) return false const lockoutMs = PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000 const elapsed = Date.now() - lastFailedAt.getTime() return elapsed < lockoutMs } /** * Compute the remaining lockout time in milliseconds (0 if unlocked). */ export function getRemainingLockoutMs( failedAttempts: number, lastFailedAt: Date | null ): number { if (!isAccountLocked(failedAttempts, lastFailedAt)) return 0 if (!lastFailedAt) return 0 const lockoutMs = PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000 const elapsed = Date.now() - lastFailedAt.getTime() return Math.max(0, lockoutMs - elapsed) } /** * Human-readable summary of the password policy. Used by the UI to * display requirements next to the password input. */ export const PASSWORD_REQUIREMENT_HINTS: string[] = [ `At least ${PASSWORD_RULES.minLength} characters`, PASSWORD_RULES.requireUppercase ? "At least one uppercase letter (A-Z)" : null, PASSWORD_RULES.requireLowercase ? "At least one lowercase letter (a-z)" : null, PASSWORD_RULES.requireNumber ? "At least one number (0-9)" : null, PASSWORD_RULES.requireSpecialChar ? "At least one special character (!@#$...)" : null, ].filter((s): s is string => Boolean(s))