From 2046f76b4f477889494fb2c5245742f052db6044 Mon Sep 17 00:00:00 2001 From: Basile Spaenlehauer Date: Tue, 5 Nov 2024 13:30:14 +0100 Subject: [PATCH] feat: make own validation for password (#688) * feat: make own validation for password * fix: add mention of the origin of the code * fix: add extensive password checks --- package.json | 4 +- src/member/password.ts | 4 +- src/validation/isPasswordStrong.test.ts | 57 +++++++++++++ src/validation/isPasswordStrong.ts | 102 ++++++++++++++++++++++++ src/validation/utils.test.ts | 42 ++++++++++ src/validation/utils.ts | 28 +++++++ yarn.lock | 16 ---- 7 files changed, 232 insertions(+), 21 deletions(-) create mode 100644 src/validation/isPasswordStrong.test.ts create mode 100644 src/validation/isPasswordStrong.ts create mode 100644 src/validation/utils.test.ts create mode 100644 src/validation/utils.ts diff --git a/package.json b/package.json index 9c1783c7..70967671 100644 --- a/package.json +++ b/package.json @@ -45,8 +45,7 @@ "dependencies": { "@faker-js/faker": "9.2.0", "filesize": "10.1.6", - "js-cookie": "3.0.5", - "validator": "13.12.0" + "js-cookie": "3.0.5" }, "peerDependencies": { "date-fns": "^3 || ^4.0.0", @@ -61,7 +60,6 @@ "@types/eslint": "^9.6.1", "@types/js-cookie": "3.0.6", "@types/uuid": "10.0.0", - "@types/validator": "13.12.2", "@typescript-eslint/eslint-plugin": "8.11.0", "@typescript-eslint/parser": "8.11.0", "date-fns": "4.1.0", diff --git a/src/member/password.ts b/src/member/password.ts index 24bf1587..4782d6d3 100644 --- a/src/member/password.ts +++ b/src/member/password.ts @@ -1,7 +1,7 @@ -import validator from 'validator'; +import { isStrongPassword } from '@/validation/isPasswordStrong.js'; export const isPasswordStrong = (password: string) => - validator.isStrongPassword(password, { + isStrongPassword(password, { minLength: 8, minLowercase: 1, minUppercase: 1, diff --git a/src/validation/isPasswordStrong.test.ts b/src/validation/isPasswordStrong.test.ts new file mode 100644 index 00000000..404889df --- /dev/null +++ b/src/validation/isPasswordStrong.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, test } from 'vitest'; + +import { isStrongPassword } from './isPasswordStrong.js'; + +const defaultOptions = { + minLength: 8, + minLowercase: 1, + minUppercase: 1, + minNumbers: 1, + minSymbols: 1, +}; + +describe('isStrongPassword', () => { + it('not a strong password', () => { + expect(isStrongPassword('', {})).toBeFalsy(); + }); + it('uses default values when not given', () => { + expect(isStrongPassword('a', { minLength: 1 })).toBeFalsy(); + // need to specify all values for the password to be strong in this setting + expect( + isStrongPassword('a', { + minLength: 1, + minLowercase: 0, + minUppercase: 0, + minNumbers: 0, + minSymbols: 0, + }), + ).toBeTruthy(); + }); + it('password is strong', () => { + expect(isStrongPassword('aTest0!zu', {})).toBeTruthy(); + }); + + test.each([ + '%2%k{7BsL"M%Kd6e', + 'EXAMPLE of very long_password123!', + 'mxH_+2vs&54_+H3P', + '+&DxJ=X7-4L8jRCD', + 'etV*p%Nr6w&H%FeF', + '£3.ndSau_7', + 'VaLIDWith\\Symb0l', + ])('valid password "%s"', (value) => { + expect(isStrongPassword(value, defaultOptions)).toBeTruthy(); + }); + + test.each([ + '', + 'password', + 'hunter2', + 'hello world', + 'passw0rd', + 'password!', + 'PASSWORD!', + ])('invalid password "%s"', (value) => { + expect(isStrongPassword(value, defaultOptions)).toBeFalsy(); + }); +}); diff --git a/src/validation/isPasswordStrong.ts b/src/validation/isPasswordStrong.ts new file mode 100644 index 00000000..6418410c --- /dev/null +++ b/src/validation/isPasswordStrong.ts @@ -0,0 +1,102 @@ +/** + * This code was adapted from the `validator.js` package + * https://github.com/validatorjs/validator.js/tree/master + */ +import { countChars, merge } from './utils.js'; + +const upperCaseRegex = /^[A-Z]$/; +const lowerCaseRegex = /^[a-z]$/; +const numberRegex = /^\d$/; +const symbolRegex = /^[-#!$@£%^&*()_+|~=`{}[\]:";'<>?,./\\ ]$/; + +const defaultOptions = { + minLength: 8, + minLowercase: 1, + minUppercase: 1, + minNumbers: 1, + minSymbols: 1, + returnScore: false, + pointsPerUnique: 1, + pointsPerRepeat: 0.5, + pointsForContainingLower: 10, + pointsForContainingUpper: 10, + pointsForContainingNumber: 10, + pointsForContainingSymbol: 10, +}; +type PasswordOptions = typeof defaultOptions; + +type PasswordAnalysis = { + length: number; + uniqueChars: number; + uppercaseCount: number; + lowercaseCount: number; + numberCount: number; + symbolCount: number; +}; + +/* Return information about a password */ +function analyzePassword(password: string): PasswordAnalysis { + const charMap = countChars(password); + const analysis = { + length: password.length, + uniqueChars: Object.keys(charMap).length, + uppercaseCount: 0, + lowercaseCount: 0, + numberCount: 0, + symbolCount: 0, + }; + Object.keys(charMap).forEach((char) => { + /* istanbul ignore else */ + if (upperCaseRegex.test(char)) { + analysis.uppercaseCount += charMap[char]; + } else if (lowerCaseRegex.test(char)) { + analysis.lowercaseCount += charMap[char]; + } else if (numberRegex.test(char)) { + analysis.numberCount += charMap[char]; + } else if (symbolRegex.test(char)) { + analysis.symbolCount += charMap[char]; + } + }); + return analysis; +} + +function scorePassword( + analysis: PasswordAnalysis, + scoringOptions: PasswordOptions, +): number { + let points = 0; + points += analysis.uniqueChars * scoringOptions.pointsPerUnique; + points += + (analysis.length - analysis.uniqueChars) * scoringOptions.pointsPerRepeat; + if (analysis.lowercaseCount > 0) { + points += scoringOptions.pointsForContainingLower; + } + if (analysis.uppercaseCount > 0) { + points += scoringOptions.pointsForContainingUpper; + } + if (analysis.numberCount > 0) { + points += scoringOptions.pointsForContainingNumber; + } + if (analysis.symbolCount > 0) { + points += scoringOptions.pointsForContainingSymbol; + } + return points; +} + +export function isStrongPassword( + str: string, + options: Partial, +) { + const analysis = analyzePassword(str); + const newOptions = merge(options || {}, defaultOptions); + if (newOptions.returnScore) { + return scorePassword(analysis, newOptions); + } + return ( + analysis.length >= newOptions.minLength && + analysis.lowercaseCount >= newOptions.minLowercase && + analysis.uppercaseCount >= newOptions.minUppercase && + analysis.numberCount >= newOptions.minNumbers && + analysis.symbolCount >= newOptions.minSymbols + ); +} diff --git a/src/validation/utils.test.ts b/src/validation/utils.test.ts new file mode 100644 index 00000000..a61009a0 --- /dev/null +++ b/src/validation/utils.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { countChars, merge } from './utils.js'; + +describe('countChars', () => { + it('count empty string', () => { + expect(countChars('')).toEqual({}); + }); + it('count small string', () => { + expect(countChars('abcd')).toEqual({ a: 1, b: 1, c: 1, d: 1 }); + }); + it('count repeating string', () => { + expect(countChars('aaaaaaaaaa')).toEqual({ a: 10 }); + }); + it('count string with spaces', () => { + expect(countChars(' aaaaaaaaaa ')).toEqual({ a: 10, ' ': 2 }); + }); +}); + +describe('merge', () => { + it('only default options', () => { + const defaultOptions = { a: 1, b: false, c: 'test' }; + expect(merge({}, { a: 1, b: false, c: 'test' })).toEqual(defaultOptions); + }); + it('provided value takes precedence over default options', () => { + const defaultOptions = { a: 1, b: false, c: 'test' }; + expect(merge({ a: 2 }, { a: 1, b: false, c: 'test' })).toEqual({ + ...defaultOptions, + a: 2, + }); + }); + it('value not in options is kept, but should be a ts error', () => { + const defaultOptions = { a: 1, b: false, c: 'test' }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + expect(merge({ a: 2, k: 'hello' }, { a: 1, b: false, c: 'test' })).toEqual({ + ...defaultOptions, + a: 2, + k: 'hello', + }); + }); +}); diff --git a/src/validation/utils.ts b/src/validation/utils.ts new file mode 100644 index 00000000..79459d15 --- /dev/null +++ b/src/validation/utils.ts @@ -0,0 +1,28 @@ +export function merge>( + obj: Partial, + defaults: T, +): T { + for (const key in defaults) { + if (typeof obj[key] === 'undefined') { + obj[key] = defaults[key]; + } + } + return obj as T; +} + +/** + * Count occurrence of characters in a string + * @param str string to process + * @returns an object with character keys and values occurrence of char + */ +export function countChars(str: string) { + const result: Record = {}; + for (const char of Array.from(str)) { + if (result[char]) { + result[char] += 1; + } else { + result[char] = 1; + } + } + return result; +} diff --git a/yarn.lock b/yarn.lock index 544ed12a..d301b0bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1019,7 +1019,6 @@ __metadata: "@types/eslint": "npm:^9.6.1" "@types/js-cookie": "npm:3.0.6" "@types/uuid": "npm:10.0.0" - "@types/validator": "npm:13.12.2" "@typescript-eslint/eslint-plugin": "npm:8.11.0" "@typescript-eslint/parser": "npm:8.11.0" date-fns: "npm:4.1.0" @@ -1034,7 +1033,6 @@ __metadata: typescript: "npm:5.6.3" unbuild: "npm:2.0.0" uuid: "npm:11.0.2" - validator: "npm:13.12.0" vite-plugin-dts: "npm:4.3.0" vitest: "npm:2.1.4" peerDependencies: @@ -1638,13 +1636,6 @@ __metadata: languageName: node linkType: hard -"@types/validator@npm:13.12.2": - version: 13.12.2 - resolution: "@types/validator@npm:13.12.2" - checksum: 10c0/64f1326c768947d756ab5bcd73f3f11a6f07dc76292aea83890d0390a9b9acb374f8df6b24af2c783271f276d3d613b78fc79491fe87edee62108d54be2e3c31 - languageName: node - linkType: hard - "@typescript-eslint/eslint-plugin@npm:8.11.0": version: 8.11.0 resolution: "@typescript-eslint/eslint-plugin@npm:8.11.0" @@ -6350,13 +6341,6 @@ __metadata: languageName: node linkType: hard -"validator@npm:13.12.0": - version: 13.12.0 - resolution: "validator@npm:13.12.0" - checksum: 10c0/21d48a7947c9e8498790550f56cd7971e0e3d724c73388226b109c1bac2728f4f88caddfc2f7ed4b076f9b0d004316263ac786a17e9c4edf075741200718cd32 - languageName: node - linkType: hard - "vite-node@npm:2.1.4": version: 2.1.4 resolution: "vite-node@npm:2.1.4"