diff --git a/README.md b/README.md index e9588c8..b464b4d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # @zxcvbn-custom-matchers The package is adding custom matchers to the zxcvbn-ts package. The matchers enforce specific character requirements in passwords and provide feedback and scoring. +> Please note! this package works only with zxcvbn-ts v.4 and above. ## Installation ```sh @@ -9,25 +10,33 @@ npm install zxcvbn-custom-matchers ### Usage ```ts -import { zxcvbnOptions } from '@zxcvbn-ts/core'; +import { ZxcvbnFactory } from '@zxcvbn-ts/core'; import { - lowercaseMatcher, numberMatcher, specialMatcher, + lowercaseMatcher, uppercaseMatcher, - minLengthMatcher + minLengthMatcher, + customMatchersTranslations } from 'zxcvbn-custom-matchers'; - -// Add the matchers -zxcvbnOptions.addMatcher('lowercase', lowercaseMatcher); -zxcvbnOptions.addMatcher('number', numberMatcher); -zxcvbnOptions.addMatcher('special', specialMatcher); -zxcvbnOptions.addMatcher('uppercase', uppercaseMatcher); -zxcvbnOptions.addMatcher('minLength', minLengthMatcher(10)); +import { merge } from 'lodash'; + +// Add the custom matchers and their translations +const options = { + translations: merge({}, zxcvbnEnPackage.translations, customMatchersTranslations) +}; +const customMatchers = { + minLength: minLengthMatcher(commons.MIN_PASSWORD_LENGTH), + specialRequired: specialMatcher, + numberRequired: numberMatcher, + lowercaseRequired: lowercaseMatcher, + uppercaseRequired: uppercaseMatcher +}; +const zxcvbn = new ZxcvbnFactory(options, customMatchers); // Use zxcvbn as usual import { zxcvbn } from '@zxcvbn-ts/core'; -const result = zxcvbn('password123'); +const result = zxcvbn.check('password123'); console.log(result); ``` diff --git a/package-lock.json b/package-lock.json index fb55021..191e3b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,12 +7,13 @@ "": { "name": "zxcvbn-custom-matchers", "version": "0.0.0", - "license": "ISC", + "license": "MIT", "devDependencies": { "@types/mocha": "^10.0.10", "@typescript-eslint/eslint-plugin": "^6.7.5", "@typescript-eslint/parser": "^6.7.5", - "@zxcvbn-ts/core": "^3.0.4", + "@zxcvbn-ts/core": "^4.0.0-beta.2", + "@zxcvbn-ts/language-en": "^3.0.2", "chai": "^4.5.0", "depcheck": "^1.4.3", "eslint": "^8.18.0", @@ -20,10 +21,14 @@ "eslint-plugin-prettier": "^5.0.1", "husky": "^8.0.3", "lint-staged": "^15.0.1", + "lodash": "^4.17.21", "mocha": "^10.2.0", "nyc": "^17.1.0", "ts-node": "^10.9.2", "typescript": "^5.3.3" + }, + "peerDependencies": { + "@zxcvbn-ts/core": "^4.0.0-beta.2" } }, "node_modules/@ampproject/remapping": { @@ -1044,15 +1049,22 @@ "license": "MIT" }, "node_modules/@zxcvbn-ts/core": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@zxcvbn-ts/core/-/core-3.0.4.tgz", - "integrity": "sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==", + "version": "4.0.0-beta.2", + "resolved": "https://registry.npmjs.org/@zxcvbn-ts/core/-/core-4.0.0-beta.2.tgz", + "integrity": "sha512-sG38FCt5+TYZ5qZ8Kl8lzX0bk2eq2yvLdfvFofY7MDDOesr1y0aR7OM2lU4kutJkZxWxV4/KXJSzinzpZv/Tbg==", "dev": true, "license": "MIT", "dependencies": { "fastest-levenshtein": "1.0.16" } }, + "node_modules/@zxcvbn-ts/language-en": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@zxcvbn-ts/language-en/-/language-en-3.0.2.tgz", + "integrity": "sha512-Zp+zL+I6Un2Bj0tRXNs6VUBq3Djt+hwTwUz4dkt2qgsQz47U0/XthZ4ULrT/RxjwJRl5LwiaKOOZeOtmixHnjg==", + "dev": true, + "license": "MIT" + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", diff --git a/package.json b/package.json index 8a30121..d62be6d 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "@types/mocha": "^10.0.10", "@typescript-eslint/eslint-plugin": "^6.7.5", "@typescript-eslint/parser": "^6.7.5", - "@zxcvbn-ts/core": "^3.0.4", + "@zxcvbn-ts/core": "^4.0.0-beta.2", + "@zxcvbn-ts/language-en": "^3.0.2", "chai": "^4.5.0", "depcheck": "^1.4.3", "eslint": "^8.18.0", @@ -36,9 +37,13 @@ "eslint-plugin-prettier": "^5.0.1", "husky": "^8.0.3", "lint-staged": "^15.0.1", + "lodash": "^4.17.21", "mocha": "^10.2.0", "nyc": "^17.1.0", "ts-node": "^10.9.2", "typescript": "^5.3.3" + }, + "peerDependencies": { + "@zxcvbn-ts/core": "^4.0.0-beta.2" } } diff --git a/src/index.ts b/src/index.ts index 02cde8e..2d4ea18 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ -export { uppercaseMatcher } from './matchers/uppercaseMatcher'; export { minLengthMatcher } from './matchers/minLengthMatcher'; +export { uppercaseMatcher } from './matchers/uppercaseMatcher'; export { lowercaseMatcher } from './matchers/lowercaseMatcher'; export { numberMatcher } from './matchers/numberMatcher'; export { specialMatcher } from './matchers/specialMatcher'; +export { customMatchersTranslations } from './translations'; diff --git a/src/matchers/lowercaseMatcher.ts b/src/matchers/lowercaseMatcher.ts index c0078e8..a30fd88 100644 --- a/src/matchers/lowercaseMatcher.ts +++ b/src/matchers/lowercaseMatcher.ts @@ -1,4 +1,4 @@ -import { Matcher, Match } from '@zxcvbn-ts/core/dist/types'; +import { Matcher, Match } from '@zxcvbn-ts/core'; export const lowercaseMatcher: Matcher = { Matching: class LowercaseMatcher { @@ -9,14 +9,17 @@ export const lowercaseMatcher: Matcher = { return matches; } } - matches.push({ pattern: 'lowercase', token: password, i: 0, j: password.length - 1 }); + matches.push({ pattern: 'lowercaseRequired', token: password, i: 0, j: password.length - 1 }); return matches; } }, - feedback(_match) { - return { warning: 'Include at least one lowercase letter.', suggestions: [] }; + feedback: options => { + return { + warning: options.translations.warnings['lowercaseRequired'] || 'lowercaseRequired', + suggestions: [options.translations.suggestions['lowercaseRequired'] || 'lowercaseRequired'], + }; }, - scoring(_match) { + scoring() { return -100; }, }; diff --git a/src/matchers/minLengthMatcher.ts b/src/matchers/minLengthMatcher.ts index c406809..92b3233 100644 --- a/src/matchers/minLengthMatcher.ts +++ b/src/matchers/minLengthMatcher.ts @@ -1,4 +1,4 @@ -import { Matcher, Match } from '@zxcvbn-ts/core/dist/types'; +import { Matcher, Match } from '@zxcvbn-ts/core'; export const minLengthMatcher = (minLength: number): Matcher => ({ Matching: class MinLengthMatcher { @@ -10,10 +10,13 @@ export const minLengthMatcher = (minLength: number): Matcher => ({ return matches; } }, - feedback(_match) { - return { warning: `Password may not be shorter than ${minLength} characters.`, suggestions: [] }; + feedback: options => { + return { + warning: options.translations.warnings['minLength']?.replace('%s', minLength) || 'minLength', + suggestions: [options.translations.suggestions['minLength']?.replace('%s', minLength) || 'minLength'], + }; }, - scoring(_match) { + scoring() { return -100; }, }); diff --git a/src/matchers/numberMatcher.ts b/src/matchers/numberMatcher.ts index 2f652d6..4e2ea3f 100644 --- a/src/matchers/numberMatcher.ts +++ b/src/matchers/numberMatcher.ts @@ -1,4 +1,4 @@ -import { Matcher, Match } from '@zxcvbn-ts/core/dist/types'; +import { Matcher, Match } from '@zxcvbn-ts/core'; export const numberMatcher: Matcher = { Matching: class NumberMatcher { @@ -9,14 +9,17 @@ export const numberMatcher: Matcher = { return matches; } } - matches.push({ pattern: 'number', token: password, i: 0, j: password.length - 1 }); + matches.push({ pattern: 'numberRequired', token: password, i: 0, j: password.length - 1 }); return matches; } }, - feedback(_match) { - return { warning: 'Include at least one number.', suggestions: [] }; + feedback: options => { + return { + warning: options.translations.warnings['numberRequired'] || 'numberRequired', + suggestions: [options.translations.suggestions['numberRequired'] || 'numberRequired'], + }; }, - scoring(_match) { + scoring() { return -100; }, }; diff --git a/src/matchers/specialMatcher.ts b/src/matchers/specialMatcher.ts index 834ed98..a0a5fc9 100644 --- a/src/matchers/specialMatcher.ts +++ b/src/matchers/specialMatcher.ts @@ -1,4 +1,4 @@ -import { Matcher, Match } from '@zxcvbn-ts/core/dist/types'; +import { Matcher, Match } from '@zxcvbn-ts/core'; export const specialMatcher: Matcher = { Matching: class SpecialMatcher { @@ -10,14 +10,17 @@ export const specialMatcher: Matcher = { return matches; } } - matches.push({ pattern: 'special', token: password, i: 0, j: password.length - 1 }); + matches.push({ pattern: 'specialRequired', token: password, i: 0, j: password.length - 1 }); return matches; } }, - feedback(_match) { - return { warning: 'Include at least one special character.', suggestions: [] }; + feedback: options => { + return { + warning: options.translations.warnings['specialRequired'] || 'specialRequired', + suggestions: [options.translations.suggestions['specialRequired'] || 'specialRequired'], + }; }, - scoring(_match) { + scoring() { return -100; }, }; diff --git a/src/matchers/uppercaseMatcher.ts b/src/matchers/uppercaseMatcher.ts index 9ed1518..ae7627b 100644 --- a/src/matchers/uppercaseMatcher.ts +++ b/src/matchers/uppercaseMatcher.ts @@ -1,4 +1,4 @@ -import { Matcher, Match } from '@zxcvbn-ts/core/dist/types'; +import { Matcher, Match } from '@zxcvbn-ts/core'; export const uppercaseMatcher: Matcher = { Matching: class UppercaseMatcher { @@ -9,14 +9,17 @@ export const uppercaseMatcher: Matcher = { return matches; } } - matches.push({ pattern: 'uppercase', token: password, i: 0, j: password.length - 1 }); + matches.push({ pattern: 'uppercaseRequired', token: password, i: 0, j: password.length - 1 }); return matches; } }, - feedback(_match) { - return { warning: 'Include at least one uppercase letter.', suggestions: [] }; + feedback: options => { + return { + warning: options.translations.warnings['uppercaseRequired'] || 'uppercaseRequired', + suggestions: [options.translations.suggestions['uppercaseRequired'] || 'uppercaseRequired'], + }; }, - scoring(_match) { + scoring() { return -100; }, }; diff --git a/src/translations.ts b/src/translations.ts new file mode 100644 index 0000000..5f8cca8 --- /dev/null +++ b/src/translations.ts @@ -0,0 +1,17 @@ +export const customMatchersTranslations = { + warnings: { + lowercaseRequired: 'At least one lowercase letter is required.', + minLength: 'Password must be at least %s characters long.', + numberRequired: 'At least one number is required.', + specialRequired: 'At least one special character is required.', + uppercaseRequired: 'At least one uppercase letter is required.', + }, + suggestions: { + lowercaseRequired: 'Include at least one lowercase letter.', + minLength: 'Password may not be shorter than %s characters.', + numberRequired: 'Include at least one number.', + specialRequired: 'Include at least one special character.', + uppercaseRequired: 'Include at least one uppercase letter.', + }, + timeEstimation: {}, +}; diff --git a/tests/unit/matchers.test.ts b/tests/unit/matchers.test.ts index 6f0e95e..6dfee92 100644 --- a/tests/unit/matchers.test.ts +++ b/tests/unit/matchers.test.ts @@ -1,120 +1,157 @@ import 'mocha'; import { expect } from 'chai'; -import { uppercaseMatcher, lowercaseMatcher, numberMatcher, specialMatcher, minLengthMatcher } from '../../src'; +import { ZxcvbnFactory, type OptionsType } from '@zxcvbn-ts/core'; +import { + uppercaseMatcher, + lowercaseMatcher, + numberMatcher, + specialMatcher, + minLengthMatcher, + customMatchersTranslations +} from '../../src'; +import { translations as baseTranslations } from '@zxcvbn-ts/language-en'; +import { merge } from 'lodash'; -describe('uppercaseMatcher', () => { - it('should return a match for missing uppercase letters', () => { - const result = uppercaseMatcher.Matching.prototype.match({ password: 'password123!' }); - expect(result).to.deep.include({ pattern: 'uppercase', token: 'password123!', i: 0, j: 11 }); - }); +const MIN_LENGTH = 12; +const MIN_SECURE_SCORE = 3; +const PERFECT_SCORE = 4; +const SAMPLE_STRONG_PASSWORD = 'de#dSh251dft!'; - it('should provide correct feedback for missing uppercase letters', () => { - const match = { pattern: 'uppercase', token: 'password123!', i: 0, j: 11, guesses: 1, guessesLog10: 0 }; - const feedback = uppercaseMatcher.feedback(match); - expect(feedback.warning).to.equal('Include at least one uppercase letter.'); - }); +// Package setup +const customMatchers = { + minLength: minLengthMatcher(MIN_LENGTH), + specialRequired: specialMatcher, + numberRequired: numberMatcher, + lowercaseRequired: lowercaseMatcher, + uppercaseRequired: uppercaseMatcher +}; - it('should return a score of -100 for missing uppercase letters', () => { - const match = { pattern: 'uppercase', token: 'password123!', i: 0, j: 11 }; - const score = uppercaseMatcher.scoring(match); - expect(score).to.equal(-100); - }); -}); +const mergedTranslations = merge({}, baseTranslations, customMatchersTranslations); +const options: OptionsType = { translations: mergedTranslations }; +const zxcvbn = new ZxcvbnFactory(options, customMatchers); -describe('lowercaseMatcher', () => { - it('should return a match for missing lowercase letters', () => { - const result = lowercaseMatcher.Matching.prototype.match({ password: 'PASSWORD123!' }); - expect(result).to.deep.include({ pattern: 'lowercase', token: 'PASSWORD123!', i: 0, j: 11 }); - }); - it('should provide correct feedback for missing lowercase letters', () => { - const match = { pattern: 'lowercase', token: 'PASSWORD123!', i: 0, j: 11, guesses: 1, guessesLog10: 0 }; - const feedback = lowercaseMatcher.feedback(match); - expect(feedback.warning).to.equal('Include at least one lowercase letter.'); - }); +describe('Password Validation Requirements', () => { + describe('Uppercase Character Requirement', () => { + const testPassword = 'password123!'; + const validPassword = 'Password123!'; - it('should return a score of -100 for missing lowercase letters', () => { - const match = { pattern: 'lowercase', token: 'PASSWORD123!', i: 0, j: 11 }; - const score = lowercaseMatcher.scoring(match); - expect(score).to.equal(-100); - }); -}); + it('should provide appropriate warning and suggestion when uppercase letters are missing', () => { + const result = zxcvbn.check(testPassword); -describe('numberMatcher', () => { - it('should return a match for missing numbers', () => { - const result = numberMatcher.Matching.prototype.match({ password: 'Password!' }); - expect(result).to.deep.include({ pattern: 'number', token: 'Password!', i: 0, j: 8 }); - }); + expect(result.feedback.warning).to.equal(customMatchersTranslations.warnings.uppercaseRequired); + expect(result.feedback.suggestions).to.include(customMatchersTranslations.suggestions.uppercaseRequired); + expect(result.score).to.be.lessThan(MIN_SECURE_SCORE); + }); - it('should provide correct feedback for missing numbers', () => { - const match = { pattern: 'number', token: 'Password!', i: 0, j: 8, guesses: 1, guessesLog10: 0 }; - const feedback = numberMatcher.feedback(match); - expect(feedback.warning).to.equal('Include at least one number.'); - }); + it('should not show uppercase warnings or suggestions when requirement is met', () => { + const result = zxcvbn.check(validPassword); - it('should return a score of -100 for missing numbers', () => { - const match = { pattern: 'number', token: 'Password!', i: 0, j: 8 }; - const score = numberMatcher.scoring(match); - expect(score).to.equal(-100); + expect(result.feedback.warning).to.not.equal(customMatchersTranslations.warnings.uppercaseRequired); + expect(result.feedback.suggestions).to.not.include(customMatchersTranslations.suggestions.uppercaseRequired); + }); }); -}); -describe('specialMatcher', () => { - it('should return a match for missing special characters', () => { - const result = specialMatcher.Matching.prototype.match({ password: 'Password123' }); - expect(result).to.deep.include({ pattern: 'special', token: 'Password123', i: 0, j: 10 }); - }); + describe('Lowercase Character Requirement', () => { + const testPassword = 'PASSWORD123!'; + const validPassword = 'PASSWORd123!'; - it('should provide correct feedback for missing special characters', () => { - const match = { pattern: 'special', token: 'Password123', i: 0, j: 10, guesses: 1, guessesLog10: 0 }; - const feedback = specialMatcher.feedback(match); - expect(feedback.warning).to.equal('Include at least one special character.'); - }); + it('should provide appropriate warning and suggestion when lowercase letters are missing', () => { + const result = zxcvbn.check(testPassword); - it('should return a score of -100 for missing special characters', () => { - const match = { pattern: 'special', token: 'Password123', i: 0, j: 10 }; - const score = specialMatcher.scoring(match); - expect(score).to.equal(-100); - }); -}); + expect(result.feedback.warning).to.equal(customMatchersTranslations.warnings.lowercaseRequired); + expect(result.feedback.suggestions).to.include(customMatchersTranslations.suggestions.lowercaseRequired); + expect(result.score).to.be.lessThan(MIN_SECURE_SCORE); + }); -describe('minLengthMatcher', () => { - const minLength = 10; - const matcher = minLengthMatcher(minLength); + it('should not show lowercase warnings or suggestions when requirement is met', () => { + const result = zxcvbn.check(validPassword); - it('should return a match for passwords shorter than the minimum length', () => { - const result = matcher.Matching.prototype.match({ password: 'short' }); - expect(result).to.deep.include({ pattern: 'minLength', token: 'short', i: 0, j: 4 }); + expect(result.feedback.warning).to.not.equal(customMatchersTranslations.warnings.lowercaseRequired); + expect(result.feedback.suggestions).to.not.include(customMatchersTranslations.suggestions.lowercaseRequired); + }); }); - it('should return no matches for passwords meeting the minimum length', () => { - const result = matcher.Matching.prototype.match({ password: 'longenoughpassword' }); - expect(result).to.be.empty; + describe('Number Requirement', () => { + const testPassword = 'Passwdfsgsdfgdsfgord!'; + const validPassword = 'Password1!'; + + it('should provide appropriate warning and suggestion when numbers are missing', () => { + const result = zxcvbn.check(testPassword); + + expect(result.feedback.warning).to.equal(customMatchersTranslations.warnings.numberRequired); + expect(result.feedback.suggestions).to.include(customMatchersTranslations.suggestions.numberRequired); + expect(result.score).to.be.lessThan(MIN_SECURE_SCORE); + }); + + it('should not show number warnings or suggestions when requirement is met', () => { + const result = zxcvbn.check(validPassword); + + expect(result.feedback.warning).to.not.equal(customMatchersTranslations.warnings.numberRequired); + expect(result.feedback.suggestions).to.not.include(customMatchersTranslations.suggestions.numberRequired); + }); }); - it('should provide correct feedback for passwords shorter than the minimum length', () => { - const match = { pattern: 'minLength', token: 'short', i: 0, j: 4, guesses: 1, guessesLog10: 0 }; - const feedback = matcher.feedback(match); - expect(feedback.warning).to.equal(`Password may not be shorter than ${minLength} characters.`); + describe('Special Character Requirement', () => { + const testPassword = 'Password0123456'; + const validPassword = 'Password123!'; + + it('should provide appropriate warning and suggestion when special characters are missing', () => { + const result = zxcvbn.check(testPassword); + + expect(result.feedback.warning).to.equal(customMatchersTranslations.warnings.specialRequired); + expect(result.feedback.suggestions).to.include(customMatchersTranslations.suggestions.specialRequired); + expect(result.score).to.be.lessThan(MIN_SECURE_SCORE); + }); + + it('should not show special character warnings or suggestions when requirement is met', () => { + const result = zxcvbn.check(validPassword); + + expect(result.feedback.warning).to.not.equal(customMatchersTranslations.warnings.specialRequired); + expect(result.feedback.suggestions).to.not.include(customMatchersTranslations.suggestions.specialRequired); + }); }); - it('should return a score of -100 for passwords shorter than the minimum length', () => { - const match = { pattern: 'minLength', token: 'short', i: 0, j: 4 }; - const score = matcher.scoring(match); - expect(score).to.equal(-100); + describe('Minimum Length Requirement', () => { + const testPassword = 'short'; + const validPassword = 'longenoughpassword'; + + it('should provide appropriate warning and suggestion for short passwords', () => { + const result = zxcvbn.check(testPassword); + const expectedWarning = customMatchersTranslations.warnings.minLength.replace('%s', String(MIN_LENGTH)); + const expectedSuggestion = customMatchersTranslations.suggestions.minLength.replace('%s', String(MIN_LENGTH)); + + expect(result.feedback.warning).to.equal(expectedWarning); + expect(result.feedback.suggestions).to.include(expectedSuggestion); + expect(result.score).to.be.lessThan(MIN_SECURE_SCORE); + }); + + it('should not show length warnings or suggestions when requirement is met', () => { + const result = zxcvbn.check(validPassword); + const unexpectedWarning = customMatchersTranslations.warnings.minLength.replace('%s', String(MIN_LENGTH)); + const unexpectedSuggestion = customMatchersTranslations.suggestions.minLength.replace('%s', String(MIN_LENGTH)); + + expect(result.feedback.warning).to.not.equal(unexpectedWarning); + expect(result.feedback.suggestions).to.not.include(unexpectedSuggestion); + }); }); -}); -describe('multiple matchers', () => { - it('should return no matches for a password meeting all requirements', () => { - const resultUppercase = uppercaseMatcher.Matching.prototype.match({ password: 'Password123!' }); - const resultLowercase = lowercaseMatcher.Matching.prototype.match({ password: 'Password123!' }); - const resultNumber = numberMatcher.Matching.prototype.match({ password: 'Password123!' }); - const resultSpecial = specialMatcher.Matching.prototype.match({ password: 'Password123!' }); - - expect(resultUppercase).to.be.empty; - expect(resultLowercase).to.be.empty; - expect(resultNumber).to.be.empty; - expect(resultSpecial).to.be.empty; + describe('Combined Requirements', () => { + it('should not show any warnings or suggestions for a fully compliant password', () => { + const result = zxcvbn.check(SAMPLE_STRONG_PASSWORD); + + expect(result.feedback.warning).to.be.null; + expect(result.feedback.suggestions).to.be.empty; + expect(result.score).to.equal(PERFECT_SCORE); + }); + + it('should provide multiple suggestions for a weak password', () => { + const result = zxcvbn.check('password'); + + expect(result.feedback.warning).to.not.be.null; + expect(result.feedback.suggestions).to.not.be.empty; + expect(result.feedback.suggestions.length).to.be.greaterThan(1); + expect(result.score).to.be.lessThan(MIN_SECURE_SCORE); + }); }); });