diff --git a/README.md b/README.md index d369d95bc..295b4500b 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ Validator | Description **isSurrogatePair(str)** | check if the string contains any surrogate pairs chars. **isUppercase(str)** | check if the string is uppercase. **isSlug** | Check if the string is of type slug. `Options` allow a single hyphen between string. e.g. [`cn-cn`, `cn-c-c`] +**isStrongPassword(str [, options])** | Check if a password is strong or not. Allows for custom requirements or scoring rules. If `returnScore` is true, then the function returns an integer score for the password rather than a boolean.
Default options:
`{ 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 }` **isTaxID(str, locale)** | Check if the given value is a valid Tax Identification Number. Default locale is `en-US` **isURL(str [, options])** | check if the string is an URL.

`options` is an object which defaults to `{ protocols: ['http','https','ftp'], require_tld: true, require_protocol: false, require_host: true, require_valid_protocol: true, allow_underscores: false, host_whitelist: false, host_blacklist: false, allow_trailing_dot: false, allow_protocol_relative_urls: false, disallow_auth: false }`.

require_protocol - if set as true isURL will return false if protocol is not present in the URL.
require_valid_protocol - isURL will check if the URL's protocol is present in the protocols option.
protocols - valid protocols can be modified with this option.
require_host - if set as false isURL will not check if host is present in the URL.
require_port - if set as true isURL will check if port is present in the URL.
allow_protocol_relative_urls - if set as true protocol relative URLs will be allowed.
validate_length - if set as false isURL will skip string length validation (2083 characters is IE max URL length). **isUUID(str [, version])** | check if the string is a UUID (version 3, 4 or 5). diff --git a/src/index.js b/src/index.js index 4b7f4ae49..78c780a45 100644 --- a/src/index.js +++ b/src/index.js @@ -115,6 +115,7 @@ import isWhitelisted from './lib/isWhitelisted'; import normalizeEmail from './lib/normalizeEmail'; import isSlug from './lib/isSlug'; +import isStrongPassword from './lib/isStrongPassword'; const version = '13.1.17'; @@ -213,6 +214,7 @@ const validator = { normalizeEmail, toString, isSlug, + isStrongPassword, isTaxID, isDate, }; diff --git a/src/lib/isStrongPassword.js b/src/lib/isStrongPassword.js new file mode 100644 index 000000000..7433703ad --- /dev/null +++ b/src/lib/isStrongPassword.js @@ -0,0 +1,96 @@ +import merge from './util/merge'; +import assertString from './util/assertString'; + +const upperCaseRegex = /^[A-Z]$/; +const lowerCaseRegex = /^[a-z]$/; +const numberRegex = /^[0-9]$/; +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, +}; + +/* Counts number of occurrences of each char in a string + * could be moved to util/ ? +*/ +function countChars(str) { + let result = {}; + Array.from(str).forEach((char) => { + let curVal = result[char]; + if (curVal) { + result[char] += 1; + } else { + result[char] = 1; + } + }); + return result; +} + +/* Return information about a password */ +function analyzePassword(password) { + let charMap = countChars(password); + let analysis = { + length: password.length, + uniqueChars: Object.keys(charMap).length, + uppercaseCount: 0, + lowercaseCount: 0, + numberCount: 0, + symbolCount: 0, + }; + Object.keys(charMap).forEach((char) => { + 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, scoringOptions) { + 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 default function isStrongPassword(str, options = null) { + assertString(str); + const analysis = analyzePassword(str); + options = merge(options || {}, defaultOptions); + if (options.returnScore) { + return scorePassword(analysis, options); + } + return analysis.length >= options.minLength + && analysis.lowercaseCount >= options.minLowercase + && analysis.uppercaseCount >= options.minUppercase + && analysis.numberCount >= options.minNumbers + && analysis.symbolCount >= options.minSymbols; +} diff --git a/test/sanitizers.js b/test/sanitizers.js index 677742731..505e11f95 100644 --- a/test/sanitizers.js +++ b/test/sanitizers.js @@ -246,6 +246,28 @@ describe('Sanitizers', () => { }); }); + it('should score passwords', () => { + test({ + sanitizer: 'isStrongPassword', + args: [{ + returnScore: true, + pointsPerUnique: 1, + pointsPerRepeat: 0.5, + pointsForContainingLower: 10, + pointsForContainingUpper: 10, + pointsForContainingNumber: 10, + pointsForContainingSymbol: 10, + }], + expect: { + abc: 13, + abcc: 13.5, + aBc: 23, + 'Abc123!': 47, + '!@#$%^&*()': 20, + }, + }); + }); + it('should normalize an email based on domain', () => { test({ sanitizer: 'normalizeEmail', diff --git a/test/validators.js b/test/validators.js index c159f38cf..dedd0366a 100644 --- a/test/validators.js +++ b/test/validators.js @@ -9355,6 +9355,35 @@ describe('Validators', () => { }); }); + it('should validate strong passwords', () => { + test({ + validator: 'isStrongPassword', + args: [{ + minLength: 8, + minLowercase: 1, + minUppercase: 1, + minNumbers: 1, + minSymbols: 1, + }], + valid: [ + '%2%k{7BsL"M%Kd6e', + 'EXAMPLE of very long_password123!', + 'mxH_+2vs&54_+H3P', + '+&DxJ=X7-4L8jRCD', + 'etV*p%Nr6w&H%FeF', + ], + invalid: [ + '', + 'password', + 'hunter2', + 'hello world', + 'passw0rd', + 'password!', + 'PASSWORD!', + ], + }); + }); + it('should validate base64URL', () => { test({ validator: 'isBase64',