Skip to content

Commit

Permalink
feat: make own validation for password (#688)
Browse files Browse the repository at this point in the history
* feat: make own validation for password

* fix: add mention of the origin of the code

* fix: add extensive password checks
  • Loading branch information
spaenleh authored Nov 5, 2024
1 parent b35ab26 commit 2046f76
Show file tree
Hide file tree
Showing 7 changed files with 232 additions and 21 deletions.
4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/member/password.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
57 changes: 57 additions & 0 deletions src/validation/isPasswordStrong.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
102 changes: 102 additions & 0 deletions src/validation/isPasswordStrong.ts
Original file line number Diff line number Diff line change
@@ -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<PasswordOptions>,
) {
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
);
}
42 changes: 42 additions & 0 deletions src/validation/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
28 changes: 28 additions & 0 deletions src/validation/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export function merge<T extends Record<string, unknown>>(
obj: Partial<T>,
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<string, number> = {};
for (const char of Array.from(str)) {
if (result[char]) {
result[char] += 1;
} else {
result[char] = 1;
}
}
return result;
}
16 changes: 0 additions & 16 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit 2046f76

Please sign in to comment.