diff --git a/packages/toolkit/core-kit/src/index.ts b/packages/toolkit/core-kit/src/index.ts index cc5986117..57cea93ca 100644 --- a/packages/toolkit/core-kit/src/index.ts +++ b/packages/toolkit/core-kit/src/index.ts @@ -3,3 +3,4 @@ export * from './regex.js'; export * from './scope.js'; export * from './models/index.js'; export * from './http.js'; +export * from './password-policy.js'; diff --git a/packages/toolkit/core-kit/src/password-policy.test.ts b/packages/toolkit/core-kit/src/password-policy.test.ts new file mode 100644 index 000000000..4dc85e1f0 --- /dev/null +++ b/packages/toolkit/core-kit/src/password-policy.test.ts @@ -0,0 +1,195 @@ +import { ZodError } from 'zod'; + +import { PasswordPolicyChecker } from './password-policy.js'; + +const { jest } = import.meta; + +const mockPwnResponse = () => { + const originalFetch = global.fetch; + beforeAll(() => { + // eslint-disable-next-line @silverhand/fp/no-mutation + global.fetch = jest.fn().mockResolvedValue({ + // Return hash suffixes for '123456'. + text: async () => + 'D032E84B0AEB4E773555C73D6B13BEA7A44:1\nD09CA3762AF61E59520943DC26494F8941B:37615252', + }); + }); + + afterAll(() => { + // eslint-disable-next-line @silverhand/fp/no-mutation + global.fetch = originalFetch; + }); +}; + +describe('PasswordPolicyChecker', () => { + it('should reject malformed policy', () => { + expect(() => { + // @ts-expect-error + return new PasswordPolicyChecker({ length: { min: 1, max: 2 } }); + }).toThrowError(ZodError); + }); +}); + +describe('PasswordPolicyChecker -> check()', () => { + mockPwnResponse(); + + const checker = new PasswordPolicyChecker({ + length: { min: 7, max: 8 }, + characterTypes: { min: 2 }, + rejects: { + pwned: true, + repetitionAndSequence: true, + words: [{ type: 'custom', value: 'test' }], + }, + }); + + it('should accept valid password', async () => { + expect(await checker.check('aL1!aL1!')).toEqual([]); + }); + + it('should reject with all failed checks', async () => { + expect(await checker.check('aaa')).toEqual([ + { code: 'password_rejected.too_short' }, + { code: 'password_rejected.character_types', interpolation: { min: 2 } }, + { code: 'password_rejected.repetition' }, + ]); + + expect(await checker.check('123456')).toEqual([ + { code: 'password_rejected.too_short' }, + { code: 'password_rejected.character_types', interpolation: { min: 2 } }, + { code: 'password_rejected.pwned' }, + { code: 'password_rejected.sequence' }, + ]); + + expect(await checker.check('aaaaaatest😀')).toEqual([ + { code: 'password_rejected.too_long' }, + { code: 'password_rejected.unsupported_characters' }, + { code: 'password_rejected.repetition' }, + { + code: 'password_rejected.restricted_words', + interpolation: { type: 'custom', value: 'test' }, + }, + ]); + }); +}); + +describe('PasswordPolicyChecker -> checkCharTypes()', () => { + const checker1 = new PasswordPolicyChecker({ + length: { min: 1, max: 256 }, + characterTypes: { min: 2 }, + rejects: { pwned: false, repetitionAndSequence: false, words: [] }, + }); + const checker2 = new PasswordPolicyChecker({ + length: { min: 1, max: 256 }, + characterTypes: { min: 4 }, + rejects: { pwned: false, repetitionAndSequence: false, words: [] }, + }); + + it('should reject unsupported characters', () => { + expect(checker1.checkCharTypes('😀')).toBe('unsupported'); + expect(checker2.checkCharTypes('aA1!😀')).toBe('unsupported'); + }); + + it('should reject password with too few character types', () => { + expect(checker1.checkCharTypes('a')).toBe(false); + expect(checker2.checkCharTypes('aA')).toBe(false); + }); + + it('should accept password with enough character types', () => { + expect(checker1.checkCharTypes('aA')).toBe(true); + expect(checker1.checkCharTypes('aA1!0')).toBe(true); + expect(checker2.checkCharTypes('aA1!0')).toBe(true); + }); +}); + +describe('PasswordPolicyChecker -> hasBeenPwned()', () => { + const checker = new PasswordPolicyChecker({ + length: { min: 1, max: 256 }, + characterTypes: { min: 2 }, + rejects: { pwned: true, repetitionAndSequence: false, words: [] }, + }); + + mockPwnResponse(); + + it('should reject pwned password', async () => { + expect(await checker.hasBeenPwned('123456')).toBe(true); + }); + + it('should accept non-pwned password', async () => { + expect(await checker.hasBeenPwned('1')).toBe(false); + }); +}); + +describe('PasswordPolicyChecker -> hasRepetition()', () => { + const checker = new PasswordPolicyChecker({ + length: { min: 1, max: 256 }, + characterTypes: { min: 2 }, + rejects: { pwned: false, repetitionAndSequence: true, words: [] }, + }); + + it('should reject password with too many repeated characters', () => { + expect(checker.hasRepetition('aaaa')).toBe(true); + expect(checker.hasRepetition('aL1!bbbbb')).toBe(true); + expect(checker.hasRepetition('aaaaaatest😀')).toBe(true); + }); + + it('should accept password with few repeated characters', () => { + expect(checker.hasRepetition('aL1!')).toBe(false); + expect(checker.hasRepetition('aL1!bbbb')).toBe(false); + }); +}); + +describe('PasswordPolicyChecker -> hasWords()', () => { + const checker = new PasswordPolicyChecker({ + length: { min: 1, max: 256 }, + characterTypes: { min: 2 }, + rejects: { + pwned: false, + repetitionAndSequence: false, + words: [ + { type: 'custom', value: 'test' }, + { type: 'custom', value: 'teSt2' }, + { type: 'personal', value: 'TesT3' }, + ], + }, + }); + + it('should reject password with blacklisted words (case insensitive)', () => { + expect(checker.hasWords('test')).toEqual([{ type: 'custom', value: 'test' }]); + expect(checker.hasWords('tEst2')).toEqual([ + { type: 'custom', value: 'test' }, + { type: 'custom', value: 'test2' }, + ]); + expect(checker.hasWords('tEST TEst2 teSt3')).toEqual([ + { type: 'custom', value: 'test' }, + { type: 'custom', value: 'test2' }, + { type: 'personal', value: 'test3' }, + ]); + }); + + it('should accept password without blacklisted words', () => { + expect(checker.hasWords('tes4')).toEqual([]); + expect(checker.hasWords('tes4 est5 tes t')).toEqual([]); + }); +}); + +describe('PasswordPolicyChecker -> hasSequentialChars()', () => { + const checker = new PasswordPolicyChecker({ + length: { min: 1, max: 256 }, + characterTypes: { min: 2 }, + rejects: { pwned: false, repetitionAndSequence: true, words: [] }, + }); + + it('should reject password with too many sequential characters', () => { + expect(checker.hasSequentialChars('FE')).toBe(true); + expect(checker.hasSequentialChars('1234')).toBe(true); + expect(checker.hasSequentialChars('edcba')).toBe(true); + expect(checker.hasSequentialChars('aL1!BCDEFA')).toBe(true); + }); + + it('should accept password with few sequential characters', () => { + expect(checker.hasSequentialChars('z')).toBe(false); + expect(checker.hasSequentialChars('aL1!')).toBe(false); + expect(checker.hasSequentialChars('aL1!bcdedcb1234321')).toBe(false); + }); +}); diff --git a/packages/toolkit/core-kit/src/password-policy.ts b/packages/toolkit/core-kit/src/password-policy.ts new file mode 100644 index 000000000..918b21e31 --- /dev/null +++ b/packages/toolkit/core-kit/src/password-policy.ts @@ -0,0 +1,319 @@ +import crypto from 'node:crypto'; + +import { z } from 'zod'; + +/** A word that used for password policy. */ +type Word = { + type: 'custom' | 'personal'; + value: string; +}; + +/** Password policy configuration type. */ +type PasswordPolicy = { + /** Policy about password length. */ + length: { + /** Minimum password length. */ + min: number; + /** Maximum password length. */ + max: number; + }; + /** + * Policy about password character types. Four types of characters are supported: + * + * - Lowercase letters (a-z). + * - Uppercase letters (A-Z). + * - Digits (0-9). + * - Symbols ({@link PasswordPolicyChecker.symbols}). + */ + characterTypes: { + /** Minimum number of character types. Range: 1-4. */ + min: number; + }; + /** Policy about what passwords to reject. */ + rejects: { + /** Whether to reject passwords that are pwned. */ + pwned: boolean; + /** Whether to reject passwords that like '123456' or 'aaaaaa'. */ + repetitionAndSequence: boolean; + /** Whether to reject passwords that include specific words. */ + words: Word[]; + }; +}; + +/** Password policy configuration guard. */ +const passwordPolicyGuard: z.ZodType = z.object({ + length: z.object({ + min: z.number().int().min(1), + max: z.number().int().min(1), + }), + characterTypes: z.object({ + min: z.number().int().min(1).max(4), + }), + rejects: z.object({ + pwned: z.boolean(), + repetitionAndSequence: z.boolean(), + words: z.array(z.object({ type: z.enum(['custom', 'personal']), value: z.string() })), + }), +}); + +/** The code of why a password is rejected. */ +type PasswordRejectionCode = + | 'too_short' + | 'too_long' + | 'character_types' + | 'unsupported_characters' + | 'pwned' + | 'repetition' + | 'sequence' + | 'restricted_words'; + +/** A password issue that does not meet the policy. */ +type PasswordIssue = { + /** Issue code. */ + code: `password_rejected.${PasswordRejectionCode}`; + /** Interpolation data for the issue message. */ + interpolation?: Record; +}; + +/** + * The class for checking if a password meets the policy. The policy is defined as + * {@link PasswordPolicy}. + * + * @example + * ```ts + * const checker = new PasswordPolicyChecker({ + * length: { min: 8, max: 256 }, + * characterTypes: { min: 2 }, + * rejects: { pwned: true, repetitionAndSequence: true, words: [] }, + * }); + * + * const issues = await checker.check('123456'); + * console.log(issues); + * // [ + * // { code: 'password_rejected.too_short' }, + * // { code: 'password_rejected.character_types', interpolation: { min: 2 } }, + * // { code: 'password_rejected.pwned' }, + * // { code: 'password_rejected.sequence' }, + * // ] + * ``` + */ +export class PasswordPolicyChecker { + static symbols = Object.freeze('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' as const); + + constructor(public readonly policy: PasswordPolicy) { + // Validate policy. + passwordPolicyGuard.parse(policy); + } + + /** + * Check if a password meets the policy. + * + * @param password - Password to check. + * @returns An array of issues. If the password meets the policy, an empty array will be returned. + */ + /* eslint-disable @silverhand/fp/no-mutating-methods */ + async check(password: string): Promise { + const issues: PasswordIssue[] = []; + + if (password.length < this.policy.length.min) { + issues.push({ + code: 'password_rejected.too_short', + }); + } else if (password.length > this.policy.length.max) { + issues.push({ + code: 'password_rejected.too_long', + }); + } + + const characterTypes = this.checkCharTypes(password); + if (characterTypes === 'unsupported') { + issues.push({ + code: 'password_rejected.unsupported_characters', + }); + } else if (!characterTypes) { + issues.push({ + code: 'password_rejected.character_types', + interpolation: { min: this.policy.characterTypes.min }, + }); + } + + if (this.policy.rejects.pwned && (await this.hasBeenPwned(password))) { + issues.push({ + code: 'password_rejected.pwned', + }); + } + + if (this.policy.rejects.repetitionAndSequence) { + if (this.hasRepetition(password)) { + issues.push({ + code: 'password_rejected.repetition', + }); + } + + if (this.hasSequentialChars(password)) { + issues.push({ + code: 'password_rejected.sequence', + }); + } + } + + issues.push( + ...this.hasWords(password).map(({ type, value }) => ({ + code: 'password_rejected.restricted_words', + interpolation: { type, value }, + })) + ); + + return issues; + } + /* eslint-enable @silverhand/fp/no-mutating-methods */ + + /** + * Check if the given password contains enough character types. + * + * @param password - Password to check. + * @returns Whether the password contains enough character types; or `'unsupported'` + * if the password contains unsupported characters. + */ + checkCharTypes(password: string): boolean | 'unsupported' { + const characterTypes = new Set(); + for (const char of password) { + if (char >= 'a' && char <= 'z') { + characterTypes.add('lowercase'); + } else if (char >= 'A' && char <= 'Z') { + characterTypes.add('uppercase'); + } else if (char >= '0' && char <= '9') { + characterTypes.add('digits'); + } else if (PasswordPolicyChecker.symbols.includes(char)) { + characterTypes.add('symbols'); + } else { + return 'unsupported'; + } + } + + return characterTypes.size >= this.policy.characterTypes.min; + } + + /** + * Check if the given password has been pwned. + * + * @param password - Password to check. + * @returns Whether the password has been pwned. + */ + async hasBeenPwned(password: string): Promise { + const hash = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(password)); + const hashHex = Array.from(new Uint8Array(hash)) + .map((binary) => binary.toString(16).padStart(2, '0')) + .join(''); + const hashPrefix = hashHex.slice(0, 5); + const hashSuffix = hashHex.slice(5); + const response = await fetch(`https://api.haveibeenpwned.com/range/${hashPrefix}`); + const text = await response.text(); + const hashes = text.split('\n'); + const found = hashes.some((hex) => hex.toLowerCase().startsWith(hashSuffix)); + + return found; + } + + /** + * Check if the given password contains repetition characters that are more than + * 4 characters or equal to the password length. + * + * @param password - Password to check. + * @returns Whether the password contains repetition characters. + */ + hasRepetition(password: string): boolean { + const matchedRepetition = password.match( + new RegExp(`(.)\\1{${Math.min(password.length - 1, 4)},}`, 'g') + ); + + return Boolean(matchedRepetition); + } + + /** + * Check if the given password contains specific words. + * + * @param password - Password to check. + * @returns An array of matched words. + */ + hasWords(password: string): Word[] { + const words = this.policy.rejects.words.map(({ value, ...rest }) => ({ + ...rest, + value: value.toLowerCase(), + })); + const lowercasedPassword = password.toLowerCase(); + + return words.filter(({ value }) => lowercasedPassword.includes(value)); + } + + /** + * Check if the given password contains sequential characters, and the number of + * sequential characters is over 4 or equal to the password length when (1 < length < 5). + * + * @param password - Password to check. + * @returns Whether the password contains sequential characters. + * + * @example + * ```ts + * hasSequentialChars('1'); // false + * hasSequentialChars('12345'); // true + * hasSequentialChars('123456@Bc.dcE'); // true + * hasSequentialChars('123@Bc.dcE'); // false + * ``` + */ + // Disable the mutation rules because the algorithm is much easier to implement with + /* eslint-disable @silverhand/fp/no-let, @silverhand/fp/no-mutation, @silverhand/fp/no-mutating-methods */ + hasSequentialChars(password: string): boolean { + let sequence: number[] = []; + + for (const char of password) { + // Always true + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const charCode = char.codePointAt(0)!; + + if (sequence.length === 0) { + sequence.push(charCode); + continue; + } + + if ( + this.checkSequentialWithChar(sequence, charCode, 1) || + this.checkSequentialWithChar(sequence, charCode, -1) + ) { + sequence.push(charCode); + } else { + sequence = [charCode]; + } + + if (sequence.length >= 5 || sequence.length === password.length) { + return true; + } + } + + return false; + } + /* eslint-enable @silverhand/fp/no-let, @silverhand/fp/no-mutation, @silverhand/fp/no-mutating-methods */ + + /** + * Check if the given char code will be sequential after appending to the given + * char code array. + */ + protected checkSequentialWithChar( + current: Readonly, + newCharCode: number, + direction: 1 | -1 + ): boolean { + const lastCharCode = current.at(-1); + + if (newCharCode - direction === lastCharCode) { + if (current.length === 1) { + return true; + } + if (current.at(-2) === newCharCode - direction * 2) { + return true; + } + } + + return false; + } +}