diff --git a/lib/auth/password.test.ts b/lib/auth/password.test.ts new file mode 100644 index 0000000..530644e --- /dev/null +++ b/lib/auth/password.test.ts @@ -0,0 +1,104 @@ +import * as bcrypt from 'bcrypt'; +import { + generateSecureSalt, + validatePasswordComplexity, + hashPassword, + verifyPassword +} from './password'; + +describe('Password Security Utilities', () => { + // Valid complex password for testing + const validPassword = 'StrongP@ssw0rd123!'; + const invalidPasswords = [ + 'short', // too short + 'onlylowercase', // missing uppercase, numbers, special chars + 'ONLYUPPERCASE', // missing lowercase, numbers, special chars + 'NoSpecialChars123', // missing special chars + ]; + + describe('generateSecureSalt', () => { + it('should generate a unique salt each time', () => { + const salt1 = generateSecureSalt(); + const salt2 = generateSecureSalt(); + + expect(salt1).toBeDefined(); + expect(salt2).toBeDefined(); + expect(salt1).not.toEqual(salt2); + }); + }); + + describe('validatePasswordComplexity', () => { + it('should validate complex password', () => { + expect(validatePasswordComplexity(validPassword)).toBe(true); + }); + + it.each(invalidPasswords)('should reject invalid password: %s', (password) => { + expect(validatePasswordComplexity(password)).toBe(false); + }); + + it('should handle empty password', () => { + expect(validatePasswordComplexity('')).toBe(false); + expect(validatePasswordComplexity(undefined as any)).toBe(false); + }); + + it('should allow custom complexity options', () => { + // Less strict options + expect(validatePasswordComplexity('simplePwd', { + minLength: 5, + requireUppercase: false, + requireLowercase: false, + requireNumbers: false, + requireSpecialChars: false + })).toBe(true); + }); + }); + + describe('hashPassword', () => { + it('should hash a valid password', async () => { + const hashedPassword = await hashPassword(validPassword); + + expect(hashedPassword).toBeTruthy(); + expect(hashedPassword).not.toEqual(validPassword); + expect(hashedPassword.length).toBeGreaterThan(0); + }); + + it('should throw error for invalid password', async () => { + await expect(hashPassword('weak')).rejects.toThrow('Password does not meet complexity requirements'); + }); + + it('should use 12 rounds of bcrypt hashing', async () => { + const bcryptHashSpy = jest.spyOn(bcrypt, 'hash'); + + await hashPassword(validPassword); + + expect(bcryptHashSpy).toHaveBeenCalledWith( + validPassword, + 12 // Verify 12 rounds are used + ); + }); + }); + + describe('verifyPassword', () => { + it('should verify correct password', async () => { + const hashedPassword = await hashPassword(validPassword); + const result = await verifyPassword(validPassword, hashedPassword); + + expect(result).toBe(true); + }); + + it('should reject incorrect password', async () => { + const hashedPassword = await hashPassword(validPassword); + const result = await verifyPassword('WrongPassword123!', hashedPassword); + + expect(result).toBe(false); + }); + + it('should handle empty inputs', async () => { + const result1 = await verifyPassword('', ''); + const result2 = await verifyPassword(undefined as any, undefined as any); + + expect(result1).toBe(false); + expect(result2).toBe(false); + }); + }); +}); diff --git a/lib/auth/password.ts b/lib/auth/password.ts new file mode 100644 index 0000000..4294027 --- /dev/null +++ b/lib/auth/password.ts @@ -0,0 +1,117 @@ +import * as crypto from 'crypto'; +import * as bcrypt from 'bcrypt'; + +// Constants for password security +const SALT_ROUNDS = 12; +const MIN_PASSWORD_LENGTH = 8; + +// Password complexity requirements +interface PasswordComplexityOptions { + minLength?: number; + requireUppercase?: boolean; + requireLowercase?: boolean; + requireNumbers?: boolean; + requireSpecialChars?: boolean; +} + +const DEFAULT_COMPLEXITY_OPTIONS: PasswordComplexityOptions = { + minLength: 8, + requireUppercase: true, + requireLowercase: true, + requireNumbers: true, + requireSpecialChars: true, +}; + +/** + * Generate a cryptographically secure random salt + * @returns Secure random salt + */ +export const generateSecureSalt = (): string => { + return crypto.randomBytes(16).toString('hex'); +}; + +/** + * Validate password complexity + * @param password - Password to validate + * @param options - Complexity requirements + * @returns Boolean indicating password meets complexity requirements + */ +export const validatePasswordComplexity = ( + password: string, + options: PasswordComplexityOptions = DEFAULT_COMPLEXITY_OPTIONS +): boolean => { + // Ensure password is not empty or undefined + if (!password) return false; + + // Check minimum length + if (password.length < (options.minLength || MIN_PASSWORD_LENGTH)) { + return false; + } + + // Uppercase check + if (options.requireUppercase && !/[A-Z]/.test(password)) { + return false; + } + + // Lowercase check + if (options.requireLowercase && !/[a-z]/.test(password)) { + return false; + } + + // Number check + if (options.requireNumbers && !/[0-9]/.test(password)) { + return false; + } + + // Special character check + if (options.requireSpecialChars && !/[!@#$%^&*(),.?":{}|<>]/.test(password)) { + return false; + } + + return true; +}; + +/** + * Securely hash a password + * @param password - Plain text password + * @returns Hashed password + * @throws Error if password is invalid + */ +export const hashPassword = async (password: string): Promise => { + // Validate password complexity before hashing + if (!validatePasswordComplexity(password)) { + throw new Error('Password does not meet complexity requirements'); + } + + try { + // Use bcrypt with 12 rounds and generate a secure salt + return await bcrypt.hash(password, SALT_ROUNDS); + } catch (error) { + console.error('Password hashing failed', error); + throw new Error('Failed to hash password'); + } +}; + +/** + * Verify a password against its hash + * @param password - Plain text password to verify + * @param hashedPassword - Stored hashed password to compare against + * @returns Boolean indicating if password is correct + */ +export const verifyPassword = async ( + password: string, + hashedPassword: string +): Promise => { + // Input validation + if (!password || !hashedPassword) { + return false; + } + + try { + // Use constant-time comparison to prevent timing attacks + return await bcrypt.compare(password, hashedPassword); + } catch (error) { + console.error('Password verification failed', error); + return false; + } +};