├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .npmignore ├── .yarn └── releases │ └── yarn-3.6.1.cjs ├── .yarnrc.yml ├── CHANGELOG.md ├── README.md ├── babel.config.js ├── jest.config.js ├── package.json ├── rollup.config.mjs ├── src ├── index.test.ts └── index.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea/ 3 | lib/ 4 | src/setupTests.ts 5 | /src/**/*.spec.ts 6 | /src/**/*.test.ts 7 | jest.config.js 8 | babel.config.js 9 | rollup.config.mjs 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "es6": true 6 | }, 7 | "extends": [ 8 | "plugin:import/recommended", 9 | "airbnb-typescript/base", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "plugin:@typescript-eslint/recommended-requiring-type-checking" 13 | ], 14 | "parser": "@typescript-eslint/parser", 15 | "parserOptions": { 16 | "ecmaVersion": 2022, 17 | "sourceType": "module", 18 | "project": "./tsconfig.json" 19 | }, 20 | "rules": { 21 | "max-len": ["error", { "code": 125 }], 22 | "no-nested-ternary": "off", 23 | "operator-linebreak": ["error", "after"], 24 | "@typescript-eslint/explicit-function-return-type": ["error", { "allowExpressions": true }], 25 | "import/no-extraneous-dependencies": ["error", { "devDependencies": true }], 26 | "@typescript-eslint/member-delimiter-style": [ 27 | "error", 28 | { 29 | "multiline": { 30 | "delimiter": "semi", 31 | "requireLast": true 32 | }, 33 | "singleline": { 34 | "delimiter": "semi", 35 | "requireLast": false 36 | } 37 | } 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea/ 3 | lib/ 4 | 5 | # Yarn 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/sdks 12 | !.yarn/versions -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .yarn/ 3 | node_modules/ 4 | .eslintignore 5 | .eslintrc.json 6 | yarn.lock 7 | .yarnrc.yml 8 | src/ -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-3.6.1.cjs 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 5.1.0 4 | - [X] Fixes bad commonjs/esm import 5 | - [X] Updates dependencies 6 | - [X] Adds this CHANGELOG.md file 7 | 8 | ## v5.0.2 9 | - [X] Fixes missing js file 10 | - [X] Fixes import syntax in js file 11 | - [X] Removes node 14 requirement 12 | 13 | ## v5.0.0 14 | ### BREAKING CHANGE 15 | - [X] Removes support for @hapi/joi. 16 | - This means only [@sideway/joi](https://github.com/sideway/joi) is supported. 17 | 18 | ## v4.2.1 19 | - [X] Adds Typescript Definitions 20 | 21 | ## v4.2.0 22 | - [X] Adds support for [@sideway/joi](https://github.com/sideway/joi) in addition to @hapi/joi 23 | 24 | Note: As joi changes over time, this module will exclusively track sideway/joi. 25 | 26 | ### Peer Dependency 27 | Peer dependency on @hapi/joi has been removed. You will receive a runtime error instead if joi is not peer-installed. 28 | In the future when we exclusively track sideway/joi, the peer dependency will be restored. 29 | 30 | ## v4.0.0 31 | - [X] [#14] Added support for Joi v16 and v17 and more descriptive error messages. 32 | 33 | ### BREAKING CHANGES 34 | - Starting with v4 only Joi v16 and higher will be supported. 35 | - Joi is now a peer dependency which should stop any mix version errors. 36 | - Minimum Nodejs version was changed to v10 or higher. 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Joi Password Complexity 2 | 3 | Creates a Joi object that validates password complexity. 4 | 5 | ## Requirements 6 | 7 | - Joi v17 or higher 8 | 9 | ## Installation 10 | 11 | `npm install joi-password-complexity` 12 | 13 | ## Examples 14 | 15 | ### No options specified 16 | 17 | ```javascript 18 | const passwordComplexity = require("joi-password-complexity"); 19 | passwordComplexity().validate("aPassword123!"); 20 | ``` 21 | 22 | When no options are specified, the following are used: 23 | 24 | ```javascript 25 | { 26 | min: 8, 27 | max: 26, 28 | lowerCase: 1, 29 | upperCase: 1, 30 | numeric: 1, 31 | symbol: 1, 32 | requirementCount: 4, 33 | } 34 | ``` 35 | 36 | ### Options specified 37 | 38 | ```javascript 39 | const passwordComplexity = require("joi-password-complexity"); 40 | 41 | const complexityOptions = { 42 | min: 10, 43 | max: 30, 44 | lowerCase: 1, 45 | upperCase: 1, 46 | numeric: 1, 47 | symbol: 1, 48 | requirementCount: 2, 49 | }; 50 | 51 | passwordComplexity(complexityOptions).validate("aPassword123!"); 52 | ``` 53 | 54 | ### Error Label (optional) Specified 55 | 56 | ```javascript 57 | const label = "Password" 58 | 59 | const passwordComplexity = require("joi-password-complexity"); 60 | ``` 61 | - For default options: 62 | 63 | ```javascript 64 | passwordComplexity(undefined, label).validate("aPassword123!"); 65 | ``` 66 | 67 | - For specified options: 68 | 69 | ```javascript 70 | passwordComplexity(complexityOptions, label).validate("aPassword123!"); 71 | ``` 72 | 73 | The resulting error message: 74 | 'Password should be at least 8 characters long' 75 | 76 | ## License 77 | 78 | MIT 79 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', {targets: {node: 'current'}}], 4 | '@babel/preset-typescript' 5 | ] 6 | }; 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['./src'], 3 | transform: { 4 | '^.+\\.tsx?$': 'babel-jest', 5 | }, 6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "joi-password-complexity", 3 | "version": "5.2.0", 4 | "description": "Joi validation for password complexity requirements.", 5 | "main": "lib/index.js", 6 | "module": "lib/index.es.js", 7 | "types": "lib/index.d.ts", 8 | "jsnext:main": "lib/index.es.js", 9 | "scripts": { 10 | "test": "jest", 11 | "lint": "eslint src --ext .js,.ts", 12 | "lint-fix": "yarn lint --fix", 13 | "build": "rimraf ./lib && rollup -c", 14 | "prepublishOnly": "yarn build" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/kamronbatman/joi-password-complexity.git" 19 | }, 20 | "peerDependencies": { 21 | "joi": ">= 17" 22 | }, 23 | "devDependencies": { 24 | "@babel/core": "^7.22.8", 25 | "@babel/preset-env": "^7.22.7", 26 | "@babel/preset-typescript": "^7.22.5", 27 | "@rollup/plugin-commonjs": "^25.0.2", 28 | "@rollup/plugin-node-resolve": "^15.1.0", 29 | "@types/jest": "^29.5.3", 30 | "@types/joi": "^17.2.2", 31 | "@typescript-eslint/eslint-plugin": "^6.0.0", 32 | "@typescript-eslint/parser": "^6.0.0", 33 | "babel-jest": "^29.6.1", 34 | "eslint": "^8.44.0", 35 | "eslint-config-airbnb-base": "^15.0.0", 36 | "eslint-config-airbnb-typescript": "^17.0.0", 37 | "eslint-plugin-import": "^2.27.5", 38 | "eslint-plugin-jest": "^27.2.2", 39 | "jest": "^29.6.1", 40 | "joi": "^17.9.2", 41 | "rimraf": "^5.0.1", 42 | "rollup": "^3.26.2", 43 | "rollup-plugin-peer-deps-external": "^2.2.4", 44 | "rollup-plugin-typescript2": "^0.35.0", 45 | "tslib": "^2.6.0", 46 | "typescript": "^5.1.6" 47 | }, 48 | "files": [ 49 | "lib/" 50 | ], 51 | "keywords": [ 52 | "Joi", 53 | "validation", 54 | "password", 55 | "complexity" 56 | ], 57 | "author": "Kamron Batman", 58 | "license": "MIT", 59 | "bugs": { 60 | "url": "https://github.com/kamronbatman/joi-password-complexity/issues" 61 | }, 62 | "homepage": "https://github.com/kamronbatman/joi-password-complexity#readme", 63 | "packageManager": "yarn@3.6.1" 64 | } 65 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2' 2 | import commonjs from '@rollup/plugin-commonjs' 3 | import external from 'rollup-plugin-peer-deps-external' 4 | import resolve from '@rollup/plugin-node-resolve' 5 | 6 | export default { 7 | input: 'src/index.ts', 8 | output: [ 9 | { 10 | file: 'lib/index.js', 11 | format: 'cjs', 12 | exports: 'default' 13 | }, 14 | { 15 | file: 'lib/index.es.js', 16 | format: 'es', 17 | exports: 'default' 18 | } 19 | ], 20 | plugins: [ 21 | external(), 22 | resolve(), 23 | typescript({ 24 | exclude: '**/__tests__/**', 25 | clean: true 26 | }), 27 | commonjs({ 28 | include: ['node_modules/**'] 29 | }) 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import passwordComplexity from './index'; 2 | 3 | describe('JoiPasswordComplexity', () => { 4 | it('should reject a password that is too short', () => { 5 | const password = '123'; 6 | 7 | const result = passwordComplexity().validate(password); 8 | const errors = result.error?.details.filter((e) => e.type === 'passwordComplexity.tooShort'); 9 | expect(errors?.length).toBe(1); 10 | expect(errors?.[0].message).toBe('"value" should be at least 8 characters long'); 11 | }); 12 | it('should reject a password that is too long', () => { 13 | const password = '123456791234567912345679123'; 14 | 15 | const result = passwordComplexity().validate(password); 16 | const errors = result.error?.details.filter((e) => e.type === 'passwordComplexity.tooLong'); 17 | 18 | expect(errors?.length).toBe(1); 19 | expect(errors?.[0].message).toBe('"value" should not be longer than 26 characters'); 20 | }); 21 | 22 | it('should reject a password that doesn\'t meet the required lowercase count', () => { 23 | const password = 'ABCDEFG'; 24 | 25 | const result = passwordComplexity().validate(password); 26 | const errors = result.error?.details.filter((e) => e.type === 'passwordComplexity.lowercase'); 27 | 28 | expect(errors?.length).toBe(1); 29 | expect(errors?.[0].message).toBe('"value" should contain at least 1 lower-cased letter'); 30 | }); 31 | 32 | it('should reject a password that doesn\'t meet the required uppercase count', () => { 33 | const password = 'abcdefg'; 34 | 35 | const result = passwordComplexity().validate(password); 36 | const errors = result.error?.details.filter((e) => e.type === 'passwordComplexity.uppercase'); 37 | 38 | expect(errors?.length).toBe(1); 39 | expect(errors?.[0].message).toBe('"value" should contain at least 1 upper-cased letter'); 40 | }); 41 | 42 | it('should reject a password that doesn\'t meet the required numeric count', () => { 43 | const password = 'ABCDEFG'; 44 | 45 | const result = passwordComplexity().validate(password); 46 | const errors = result.error?.details.filter((e) => e.type === 'passwordComplexity.numeric'); 47 | 48 | expect(errors?.length).toBe(1); 49 | expect(errors?.[0].message).toBe('"value" should contain at least 1 number'); 50 | }); 51 | 52 | it('should reject a password that doesn\'t meet the required symbol count', () => { 53 | const password = 'ABCDEFG'; 54 | 55 | const result = passwordComplexity().validate(password); 56 | const errors = result.error?.details.filter((e) => e.type === 'passwordComplexity.symbol'); 57 | 58 | expect(errors?.length).toBe(1); 59 | expect(errors?.[0].message).toBe('"value" should contain at least 1 symbol'); 60 | }); 61 | 62 | it('should accept a valid password with default options', () => { 63 | const password = 'abCD12#$'; 64 | const result = passwordComplexity().validate(password); 65 | 66 | expect(result.error).toBeUndefined(); 67 | }); 68 | 69 | it('should accept a password that meets a requirement count', () => { 70 | const password = 'Password123'; 71 | 72 | const result = passwordComplexity({ 73 | min: 8, 74 | max: 30, 75 | lowerCase: 1, 76 | upperCase: 1, 77 | numeric: 1, 78 | symbol: 1, 79 | requirementCount: 3, 80 | }).validate(password); 81 | 82 | expect(result.error).toBeUndefined(); 83 | }); 84 | 85 | it('should accept a password that has no requirement count', () => { 86 | const password = 'Password123'; 87 | const result = passwordComplexity({ 88 | min: 8, 89 | max: 30, 90 | lowerCase: 1, 91 | upperCase: 1, 92 | numeric: 1, 93 | symbol: 0, 94 | requirementCount: 0, 95 | }).validate(password); 96 | 97 | expect(result.error).toBeUndefined(); 98 | }); 99 | 100 | it('should reject a password that fails other requirements without a requirement count', () => { 101 | const password = 'Password'; 102 | const result = passwordComplexity({ 103 | min: 8, 104 | max: 30, 105 | lowerCase: 1, 106 | upperCase: 1, 107 | numeric: 1, 108 | symbol: 0, 109 | requirementCount: 0, 110 | }).validate(password); 111 | 112 | const errors = result.error?.details.filter((e) => e.type === 'passwordComplexity.numeric'); 113 | 114 | expect(errors?.length).toBe(1); 115 | expect(errors?.[0].message).toBe('"value" should contain at least 1 number'); 116 | }); 117 | 118 | it('should treat a requirement count that is higher than 4 as no requirement count', () => { 119 | const password = 'Password12'; 120 | const result = passwordComplexity({ 121 | min: 8, 122 | max: 30, 123 | lowerCase: 1, 124 | upperCase: 1, 125 | numeric: 1, 126 | symbol: 0, 127 | requirementCount: 100, 128 | }).validate(password); 129 | 130 | expect(result.error).toBeUndefined(); 131 | }); 132 | 133 | it('should display custom error label when supplied', () => { 134 | const password = '123'; 135 | 136 | const result = passwordComplexity(undefined, 'Password').validate(password); 137 | const errors = result.error?.details.filter((e) => e.type === 'passwordComplexity.tooShort'); 138 | 139 | expect(errors?.[0].message).toBe('Password should be at least 8 characters long'); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Joi, { CustomHelpers, Extension } from 'joi'; 2 | 3 | // pluralize 4 | const p = (word: string, num = 0): string => (num === 1 ? word : `${word}s`); 5 | const isPositive = (num = 0): number => Number(num > 0); 6 | const clamp = (value: number, min: number, max: number): number => (value < min ? min : value > max ? max : value); 7 | 8 | const defaultOptions: ComplexityOptions = { 9 | min: 8, 10 | max: 26, 11 | lowerCase: 1, 12 | upperCase: 1, 13 | numeric: 1, 14 | symbol: 1, 15 | requirementCount: 4, 16 | }; 17 | 18 | export interface JoiPasswordComplexity extends Joi.StringSchema { 19 | passwordComplexity(): this; 20 | } 21 | 22 | export interface ComplexityOptions { 23 | min?: number; 24 | max?: number; 25 | lowerCase?: number; 26 | upperCase?: number; 27 | numeric?: number; 28 | symbol?: number; 29 | requirementCount?: number; 30 | } 31 | 32 | export default ({ 33 | min = 0, 34 | max = 0, 35 | lowerCase = 0, 36 | upperCase = 0, 37 | numeric = 0, 38 | symbol = 0, 39 | requirementCount = 0, 40 | }: ComplexityOptions = defaultOptions, label = '{{#label}}'): JoiPasswordComplexity => { 41 | const joiPasswordComplexity: Extension = { 42 | type: 'passwordComplexity', 43 | base: Joi.string(), 44 | messages: { 45 | 'passwordComplexity.tooShort': 46 | `${label} should be at least ${min} ${p('character', min)} long`, 47 | 'passwordComplexity.tooLong': 48 | `${label} should not be longer than ${max} ${p('character', max)}`, 49 | 'passwordComplexity.lowercase': 50 | `${label} should contain at least ${lowerCase} lower-cased ${p('letter', lowerCase)}`, 51 | 'passwordComplexity.uppercase': 52 | `${label} should contain at least ${upperCase} upper-cased ${p('letter', upperCase)}`, 53 | 'passwordComplexity.numeric': 54 | `${label} should contain at least ${numeric} ${p('number', numeric)}`, 55 | 'passwordComplexity.symbol': 56 | `${label} should contain at least ${symbol} ${p('symbol', symbol)}`, 57 | 'passwordComplexity.requirementCount': 58 | `${label} must meet at least ${requirementCount} of the complexity requirements`, 59 | }, 60 | validate: (value: unknown, helpers: CustomHelpers) => { 61 | const errors = []; 62 | 63 | if (typeof value === 'string') { 64 | const lowercaseCount = value.match(/[a-z]/g)?.length ?? 0; 65 | const upperCaseCount = value.match(/[A-Z]/g)?.length ?? 0; 66 | const numericCount = value.match(/[0-9]/g)?.length ?? 0; 67 | const symbolCount = value.match(/[^a-zA-Z0-9]/g)?.length ?? 0; 68 | 69 | const meetsMin = min && value.length >= min; 70 | const meetsMax = max && value.length <= max; 71 | 72 | const meetsLowercase = lowercaseCount >= (lowerCase); 73 | const meetsUppercase = upperCaseCount >= (upperCase); 74 | const meetsNumeric = numericCount >= (numeric); 75 | const meetsSymbol = symbolCount >= (symbol); 76 | 77 | const maxRequirement = isPositive(lowerCase) + isPositive(upperCase) + 78 | isPositive(numeric) + isPositive(symbol); 79 | 80 | const requirement = clamp(requirementCount || maxRequirement, 1, maxRequirement); 81 | 82 | const requirementErrors = []; 83 | 84 | if (!meetsMin) errors.push(helpers.error('passwordComplexity.tooShort', { value })); 85 | if (!meetsMax) errors.push(helpers.error('passwordComplexity.tooLong', { value })); 86 | if (!meetsLowercase) { 87 | requirementErrors.push(helpers.error('passwordComplexity.lowercase', { value })); 88 | } 89 | if (!meetsUppercase) { 90 | requirementErrors.push(helpers.error('passwordComplexity.uppercase', { value })); 91 | } 92 | if (!meetsNumeric) { 93 | requirementErrors.push(helpers.error('passwordComplexity.numeric', { value })); 94 | } 95 | if (!meetsSymbol) { 96 | requirementErrors.push(helpers.error('passwordComplexity.symbol', { value })); 97 | } 98 | 99 | if (maxRequirement - requirementErrors.length < requirement) { 100 | errors.push(...requirementErrors); 101 | if (requirement < maxRequirement) { 102 | errors.push(helpers.error('passwordComplexity.requirementCount', { value })); 103 | } 104 | } 105 | } 106 | 107 | return { 108 | value, 109 | errors: errors.length ? errors : null, 110 | }; 111 | }, 112 | }; 113 | 114 | return (Joi.extend(joiPasswordComplexity) as JoiPasswordComplexity).passwordComplexity(); 115 | }; 116 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "lib", 4 | "target": "es2020", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "es2022", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "declaration": true, 17 | "typeRoots": [ 18 | "src/@types", 19 | "node_modules/@types" 20 | ], 21 | "types": ["jest"] 22 | }, 23 | "include": ["src"], 24 | "exclude": ["lib", "node_modules", "**/*.spec.ts", "**/*.test.ts"] 25 | } 26 | --------------------------------------------------------------------------------