Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions lib/auth/password.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
117 changes: 117 additions & 0 deletions lib/auth/password.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
// 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<boolean> => {
// 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;
}
};