0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(toolkit): update password policy

This commit is contained in:
Gao Sun 2023-09-12 00:30:57 +08:00
parent 9644fa0615
commit 527c1a7743
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
2 changed files with 249 additions and 215 deletions

View file

@ -34,18 +34,33 @@ describe('PasswordPolicyChecker -> check()', () => {
mockPwnResponse();
const checker = new PasswordPolicyChecker({
length: { min: 7, max: 8 },
characterTypes: { min: 2 },
length: { min: 7, max: 15 },
characterTypes: { min: 3 },
rejects: {
pwned: true,
repetitionAndSequence: true,
userInfo: true,
words: ['aaaaaate', 'aaaaaaaa'],
words: ['aaaaaate', 'aaaaaaaa', 'silverhand'],
},
});
it('should accept valid password', async () => {
expect(await checker.check('aL1!aL1!', {})).toEqual([]);
expect(await checker.check('silverHAnd213', {})).toEqual([]);
expect(await checker.check('lo9KI8mJu112', {})).toEqual([]);
});
it('should recognize rejection combinations', async () => {
expect(await checker.check('aL1!aL1!', { name: 'aL1!' })).toEqual([
{ code: 'password_rejected.restricted.user_info' },
]);
expect(await checker.check('lo9KI8mJu78911', {})).toEqual([
{ code: 'password_rejected.restricted.sequence' },
]);
expect(await checker.check('lo9KI8mJu789111', {})).toEqual([
{ code: 'password_rejected.restricted.sequence' },
{ code: 'password_rejected.restricted.repetition' },
]);
});
it('should reject with all failed checks', async () => {
@ -57,20 +72,19 @@ describe('PasswordPolicyChecker -> check()', () => {
expect(await checker.check('123456', { phoneNumber: '12345' })).toEqual([
{ code: 'password_rejected.too_short', interpolation: { min: 7 } },
{ code: 'password_rejected.character_types', interpolation: { min: 2 } },
{ code: 'password_rejected.character_types', interpolation: { min: 3 } },
{ code: 'password_rejected.pwned' },
{ code: 'password_rejected.restricted.sequence' },
{ code: 'password_rejected.restricted.user_info' },
]);
expect(await checker.check('aaaaaaaate', {})).toEqual([
{ code: 'password_rejected.too_long', interpolation: { max: 8 } },
{ code: 'password_rejected.character_types', interpolation: { min: 2 } },
expect(await checker.check('aAaAaAaAaAaAaAaAaAteABcOK', { name: 'CO' })).toEqual([
{ code: 'password_rejected.too_long', interpolation: { max: 15 } },
{ code: 'password_rejected.character_types', interpolation: { min: 3 } },
{ code: 'password_rejected.restricted.repetition' },
{
code: 'password_rejected.restricted.words',
interpolation: { words: 'aaaaaate\naaaaaaaa', count: 2 },
},
{ code: 'password_rejected.restricted.words' },
{ code: 'password_rejected.restricted.sequence' },
{ code: 'password_rejected.restricted.user_info' },
]);
});
});
@ -122,61 +136,76 @@ describe('PasswordPolicyChecker -> hasBeenPwned()', () => {
});
});
describe('PasswordPolicyChecker -> hasRepetition()', () => {
describe('PasswordPolicyChecker -> repetitionLength()', () => {
const checker = new PasswordPolicyChecker({
length: { min: 1, max: 256 },
characterTypes: { min: 2 },
rejects: { pwned: false, repetitionAndSequence: true, words: [] },
});
it('should reject password with too many repeated characters', () => {
expect(checker.hasRepetition('aaaa')).toBe(true);
expect(checker.hasRepetition('aaa12')).toBe(true);
expect(checker.hasRepetition('aaaaaa😀')).toBe(true);
it('should recognize repeated characters that start at the beginning', () => {
expect(checker.repetitionLength('aaaa')).toBe(4);
expect(checker.repetitionLength('aaa12')).toBe(3);
expect(checker.repetitionLength('aaAaAa😀')).toBe(6);
});
it('should accept password with few repeated characters', () => {
expect(checker.hasRepetition('a')).toBe(false);
expect(checker.hasRepetition('aa')).toBe(false);
expect(checker.hasRepetition('aL!bbbbb')).toBe(false);
expect(checker.hasRepetition('aL1!')).toBe(false);
expect(checker.hasRepetition('aL1!bbbbbbbbbbbb')).toBe(false);
it('should ignore repeated characters that do not start at the beginning or are too short', () => {
expect(checker.repetitionLength('a')).toBe(0);
expect(checker.repetitionLength('aa')).toBe(0);
expect(checker.repetitionLength('aL!bbbbb')).toBe(0);
expect(checker.repetitionLength('aL1!')).toBe(0);
expect(checker.repetitionLength('aL1!bbbbbbbbbbbb')).toBe(0);
});
});
describe('PasswordPolicyChecker -> hasUserInfo()', () => {
describe('PasswordPolicyChecker -> userInfoLength()', () => {
const checker = new PasswordPolicyChecker({
rejects: { pwned: false, repetitionAndSequence: false, userInfo: true, words: [] },
});
it('should reject password with name', () => {
expect(checker.hasUserInfo('test', { name: 'test' })).toBe(true);
expect(checker.hasUserInfo('test', { name: 'test2' })).toBe(false);
expect(checker.hasUserInfo('FOO', { name: 'Foo bar' })).toBe(true);
expect(checker.hasUserInfo('Foo', { name: 'bar fOo' })).toBe(true);
expect(checker.hasUserInfo('barFooBaz12', { name: 'bar fOo baz' })).toBe(true);
it('should recognize name', () => {
expect(checker.userInfoLength('test', { name: 'test' })).toBe(4);
expect(checker.userInfoLength('test', { name: 'test2' })).toBe(0);
expect(checker.userInfoLength('FOO', { name: 'Foo bar' })).toBe(3);
expect(checker.userInfoLength('Foo', { name: 'bar fOo' })).toBe(3);
expect(checker.userInfoLength('barFooBaz12', { name: 'bar fOo baz' })).toBe(9);
expect(checker.userInfoLength('bar fOo baz12', { name: 'bar fOo baz' })).toBe(15);
expect(checker.userInfoLength('bar fOo baz12', { name: 'bar fOo baz' })).toBe(3);
expect(checker.userInfoLength('barfOo baz12', { name: 'bar fOo baz' })).toBe(3);
});
it('should reject password with username', () => {
expect(checker.hasUserInfo('1!test', { username: 'teST' })).toBe(true);
expect(checker.hasUserInfo('test123', { username: 'test2' })).toBe(false);
it('should recognize username', () => {
expect(checker.userInfoLength('test1!', { username: 'teST' })).toBe(4);
expect(checker.userInfoLength('test123', { username: 'test2' })).toBe(0);
});
it('should reject password with email', () => {
expect(checker.hasUserInfo('teST1', { email: 'test@foo.com' })).toBe(true);
expect(checker.hasUserInfo('TEST2', { email: 'test1@foo.com' })).toBe(false);
expect(checker.hasUserInfo('FOO', { email: 'test@foo.com' })).toBe(false);
expect(checker.hasUserInfo('Foo!', { email: 'fOO@foo.com' })).toBe(true);
it('should recognize email', () => {
expect(checker.userInfoLength('teST1', { email: 'test@foo.com' })).toBe(4);
expect(checker.userInfoLength('TEST2', { email: 'test1@foo.com' })).toBe(0);
expect(checker.userInfoLength('FOO', { email: 'test@foo.com' })).toBe(0);
expect(checker.userInfoLength('Foo!', { email: 'fOO@foo.com' })).toBe(3);
});
it('should reject password with phone number', () => {
expect(checker.hasUserInfo('ST123456789', { phoneNumber: '123456789' })).toBe(true);
expect(checker.hasUserInfo('teST1234567890.', { phoneNumber: '123456789' })).toBe(false);
expect(checker.hasUserInfo('TEST12345678', { phoneNumber: '123456789' })).toBe(false);
it('should recognize phone number', () => {
expect(checker.userInfoLength('123456789ST', { phoneNumber: '123456789' })).toBe(9);
expect(checker.userInfoLength('123456789ST', { phoneNumber: '12' })).toBe(2);
expect(checker.userInfoLength('teST1234567890.', { phoneNumber: '123456789' })).toBe(0);
expect(checker.userInfoLength('TEST12345678', { phoneNumber: '123456789' })).toBe(0);
});
it('should return the longest match', () => {
expect(
checker.userInfoLength('123456789ST', {
name: '1234 56789',
username: '12345',
email: '1234567',
phoneNumber: '1234',
})
).toBe(9);
});
});
describe('PasswordPolicyChecker -> hasWords()', () => {
describe('PasswordPolicyChecker -> wordLength()', () => {
const checker = new PasswordPolicyChecker({
length: { min: 1, max: 256 },
characterTypes: { min: 2 },
@ -188,36 +217,38 @@ describe('PasswordPolicyChecker -> hasWords()', () => {
},
});
it('should reject password with blacklisted words (case insensitive)', () => {
expect(checker.hasWords('test')).toEqual(['test']);
expect(checker.hasWords('tEst2')).toEqual(['test', 'test2']);
it('should recognize blacklisted words (case insensitive)', () => {
expect(checker.wordLength('test')).toEqual(4);
expect(checker.wordLength('tEst2')).toEqual(5);
});
it('should accept password without blacklisted words', () => {
expect(checker.hasWords('tes4')).toEqual([]);
expect(checker.hasWords('tes4 est5 tes t')).toEqual([]);
expect(checker.hasWords('tEST TEst2 teSt3')).toEqual([]);
it('should ignore other words', () => {
expect(checker.wordLength('tes4')).toEqual(0);
expect(checker.wordLength('tes4 est5 tes t')).toEqual(0);
expect(checker.wordLength('tES4 TEst2 teSt3')).toEqual(0);
});
});
describe('PasswordPolicyChecker -> hasSequentialChars()', () => {
describe('PasswordPolicyChecker -> sequenceLength()', () => {
const checker = new PasswordPolicyChecker({
length: { min: 1, max: 256 },
characterTypes: { min: 2 },
rejects: { pwned: false, repetitionAndSequence: true, userInfo: false, words: [] },
});
it('should reject password with too many sequential characters', () => {
expect(checker.hasSequentialChars('1234')).toBe(true);
expect(checker.hasSequentialChars('edcba')).toBe(true);
expect(checker.hasSequentialChars('BCDEDC')).toBe(true);
it('should recognize string starts with too many sequential characters', () => {
expect(checker.sequenceLength('1234')).toBe(4);
expect(checker.sequenceLength('edcba')).toBe(5);
expect(checker.sequenceLength('BCDEDC')).toBe(4);
expect(checker.sequenceLength('yuIOp##')).toBe(5);
expect(checker.sequenceLength('2wsx3edc1')).toBe(4);
expect(checker.sequenceLength('lo9KI8mJu7890')).toBe(3);
});
it('should accept password with few sequential characters', () => {
expect(checker.hasSequentialChars('z')).toBe(false);
expect(checker.hasSequentialChars('FE')).toBe(false);
expect(checker.hasSequentialChars('aL1!')).toBe(false);
expect(checker.hasSequentialChars('aL1!BCDEFA')).toBe(false);
expect(checker.hasSequentialChars('aL1!bcdedcb1234321')).toBe(false);
it('should ignore string starts with too few sequential characters', () => {
expect(checker.sequenceLength('z')).toBe(0);
expect(checker.sequenceLength('FE')).toBe(0);
expect(checker.sequenceLength('aL1!')).toBe(0);
expect(checker.sequenceLength('aL1!BCDEFA')).toBe(0);
});
});

View file

@ -45,7 +45,7 @@ export const passwordPolicyGuard = z.object({
.default({}),
characterTypes: z
.object({
min: z.number().int().min(1).max(4).optional().default(2),
min: z.number().int().min(1).max(4).optional().default(1),
})
.default({}),
rejects: z
@ -110,6 +110,25 @@ export type UserInfo = Partial<{
*/
export class PasswordPolicyChecker {
static symbols = Object.freeze('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' as const);
/** A set of characters that are considered as sequential. */
static sequence = Object.freeze([
'0123456789',
'abcdefghijklmnopqrstuvwxyz',
'qwertyuiop',
'asdfghjkl',
'zxcvbnm',
'1qaz',
'2wsx',
'3edc',
'4rfv',
'5tgb',
'6yhn',
'7ujm',
'8ik',
'9ol',
] as const);
/** The length threshold for checking repetition and sequence. */
static repetitionAndSequenceThreshold = 3 as const;
/**
@ -145,8 +164,7 @@ export class PasswordPolicyChecker {
* @throws TypeError - If the policy requires to reject passwords that include user information
* but the user information is not provided.
*/
/* eslint-disable @silverhand/fp/no-mutating-methods */
/* eslint-disable @silverhand/fp/no-let, @silverhand/fp/no-mutation, @silverhand/fp/no-mutating-methods */
async check(password: string, userInfo?: UserInfo): Promise<PasswordIssue[]> {
const issues: PasswordIssue[] = this.fastCheck(password);
@ -156,43 +174,55 @@ export class PasswordPolicyChecker {
});
}
if (this.policy.rejects.repetitionAndSequence) {
if (this.hasRepetition(password)) {
issues.push({
code: 'password_rejected.restricted.repetition',
});
// `hashArray[i]` indicates whether the `i`th character violates the restriction.
// We'll gradually set the value to `1` if needed.
// The algorithm time complexity should be O(n^2), but it's fast enough for a password.
const hashArray = Array.from<0 | 1>({ length: password.length }).fill(0);
const issueCodes = new Set<PasswordRejectionCode>();
const { repetitionAndSequence, words, userInfo: rejectUserInfo } = this.policy.rejects;
const rejectWords = words.length > 0;
const fillHashArray = (startIndex: number, length: number, code: PasswordRejectionCode) => {
if (length <= 0) {
return;
}
if (this.hasSequentialChars(password)) {
issues.push({
code: 'password_rejected.restricted.sequence',
});
for (let i = startIndex; i < startIndex + length; i += 1) {
hashArray[i] = 1;
}
issueCodes.add(code);
};
for (let i = 0; i < password.length; i += 1) {
const sliced = password.slice(i);
if (repetitionAndSequence) {
fillHashArray(i, this.repetitionLength(sliced), 'restricted.repetition');
fillHashArray(i, this.sequenceLength(sliced), 'restricted.sequence');
}
if (rejectWords) {
fillHashArray(i, this.wordLength(sliced), 'restricted.words');
}
if (rejectUserInfo) {
if (!userInfo) {
throw new TypeError('User information data is required to check user information.');
}
fillHashArray(i, this.userInfoLength(sliced, userInfo), 'restricted.user_info');
}
}
const words = this.hasWords(password);
if (words.length > 0) {
issues.push({
code: 'password_rejected.restricted.words',
interpolation: { words: words.join('\n'), count: words.length },
});
}
if (this.policy.rejects.userInfo) {
if (!userInfo) {
throw new TypeError('User information is required to check user information.');
}
if (this.hasUserInfo(password, userInfo)) {
issues.push({
code: 'password_rejected.restricted.user_info',
});
}
}
return issues;
return hashArray.reduce<number>((total, current) => total + current, 0) >
PasswordPolicyChecker.getRestrictedPhraseThreshold(password)
? [
...issues,
...[...issueCodes].map<PasswordIssue>((code) => ({ code: `password_rejected.${code}` })),
]
: issues;
}
/* eslint-enable @silverhand/fp/no-let, @silverhand/fp/no-mutation, @silverhand/fp/no-mutating-methods */
/**
* Perform a fast check to see if the password passes the basic requirements.
@ -203,6 +233,7 @@ export class PasswordPolicyChecker {
* @param password - Password to check.
* @returns Whether the password passes the basic requirements.
*/
/* eslint-disable @silverhand/fp/no-mutating-methods */
fastCheck(password: string) {
const issues: PasswordIssue[] = [];
@ -282,171 +313,143 @@ export class PasswordPolicyChecker {
}
/**
* Check if the given password contains repetition characters that are more than
* the threshold.
* Get the length of the repetition at the beginning of the given string.
* For example, `repetitionLength('aaaaa')` will return `5`.
*
* @param password - Password to check.
* @returns Whether the password contains repetition characters.
* If the length is less than {@link PasswordPolicyChecker.repetitionAndSequenceThreshold},
* `0` will be returned.
*/
hasRepetition(password: string): boolean {
const { repetitionAndSequenceThreshold, getRestrictedPhraseThreshold } = PasswordPolicyChecker;
/* eslint-disable @silverhand/fp/no-let, @silverhand/fp/no-mutation */
repetitionLength(password: string): number {
const { repetitionAndSequenceThreshold } = PasswordPolicyChecker;
const firstChar = password[0]?.toLowerCase();
let length = 0;
if (password.length < repetitionAndSequenceThreshold) {
return false;
for (const char of password) {
if (char.toLowerCase() === firstChar) {
length += 1;
} else {
break;
}
}
const matchedRepetition = password.match(
new RegExp(`(.)\\1{${getRestrictedPhraseThreshold(password)},}`, 'g')
);
return Boolean(matchedRepetition);
return length >= repetitionAndSequenceThreshold ? length : 0;
}
/* eslint-enable @silverhand/fp/no-let, @silverhand/fp/no-mutation */
/**
* Check if the given password contains user information.
* Get the length of the user information at the beginning of the given string.
* For example, `userInfoLength('silverhand', { username: 'silverhand' })` will return `10`.
*
* @param password - Password to check.
* @param userInfo - User information to check.
* @returns Whether the password contains user information.
* For multiple matches, the longest length will be returned.
*/
// eslint-disable-next-line complexity
hasUserInfo(password: string, userInfo: UserInfo): boolean {
const lowercasedPassword = password.toLowerCase();
userInfoLength(password: string, userInfo: UserInfo): number {
const lowercased = password.toLowerCase();
const { name, username, email, phoneNumber } = userInfo;
const threshold = PasswordPolicyChecker.getRestrictedPhraseThreshold(password);
// eslint-disable-next-line @silverhand/fp/no-let
let length = 0;
const updateLength = (newLength: number) => {
if (newLength > length) {
// eslint-disable-next-line @silverhand/fp/no-mutation
length = newLength;
}
};
if (name) {
const joined = name.replaceAll(/\s+/g, '');
if (joined.length >= threshold && lowercasedPassword.includes(joined.toLowerCase())) {
return true;
}
if (
name
.toLowerCase()
.split(' ')
.some((word) => word.length >= threshold && lowercasedPassword.includes(word))
) {
return true;
// The original name should be the longest string, so we check it first.
if (lowercased.startsWith(name.toLowerCase())) {
updateLength(name.length);
} else {
if (lowercased.startsWith(joined.toLowerCase())) {
updateLength(joined.length);
}
for (const word of name.split(' ')) {
if (lowercased.startsWith(word.toLowerCase())) {
updateLength(word.length);
}
}
}
}
if (
username &&
username.length >= threshold &&
lowercasedPassword.includes(username.toLowerCase())
) {
return true;
if (username && lowercased.startsWith(username.toLowerCase())) {
updateLength(username.length);
}
const emailPrefix = email?.split('@')[0];
if (
emailPrefix &&
emailPrefix.length >= threshold &&
lowercasedPassword.includes(emailPrefix.toLowerCase())
) {
return true;
if (email) {
const emailPrefix = email.split('@')[0];
if (emailPrefix && lowercased.startsWith(emailPrefix.toLowerCase())) {
updateLength(emailPrefix.length);
}
}
if (
phoneNumber &&
phoneNumber.length >= threshold &&
lowercasedPassword.includes(phoneNumber)
) {
return true;
if (phoneNumber && lowercased.startsWith(phoneNumber)) {
updateLength(phoneNumber.length);
}
return false;
return length;
}
/**
* Check if the given password contains specific words.
* Get the length of the word that matches the word list at the beginning of the given string.
*
* @param password - Password to check.
* @returns An array of matched words.
* For multiple matches, the longest length will be returned.
*/
hasWords(password: string): string[] {
const words = this.policy.rejects.words.map((word) => word.toLowerCase());
const lowercasedPassword = password.toLowerCase();
const threshold = PasswordPolicyChecker.getRestrictedPhraseThreshold(password);
/* eslint-disable @silverhand/fp/no-let, @silverhand/fp/no-mutation */
wordLength(password: string): number {
const sliced = password.toLowerCase();
let length = 0;
return words.filter((word) => word.length >= threshold && lowercasedPassword.includes(word));
}
/**
* Check if the given password contains sequential characters, and the number of
* sequential characters is over the threshold.
*
* @param password - Password to check.
* @returns Whether the password contains sequential characters.
*
* @example
* ```ts
* hasSequentialChars('1'); // false
* hasSequentialChars('12'); // false
* hasSequentialChars('12345'); // true
* hasSequentialChars('123456@Bc.dcE'); // true
* hasSequentialChars('123@Bc.dcE'); // false
* ```
*/
// Disable the mutation rules because the algorithm is much easier to implement with
/* eslint-disable @silverhand/fp/no-let, @silverhand/fp/no-mutation, @silverhand/fp/no-mutating-methods */
hasSequentialChars(password: string): boolean {
const { repetitionAndSequenceThreshold, getRestrictedPhraseThreshold } = PasswordPolicyChecker;
if (password.length < repetitionAndSequenceThreshold) {
return false;
for (const word of this.policy.rejects.words) {
if (sliced.startsWith(word.toLowerCase()) && word.length > length) {
length = word.length;
}
}
const threshold = Math.max(
getRestrictedPhraseThreshold(password),
repetitionAndSequenceThreshold
);
let sequence: number[] = [];
return length;
}
/* eslint-enable @silverhand/fp/no-let, @silverhand/fp/no-mutation */
/**
* Get the length of the sequence at the beginning of the given string.
* For example, `sequenceLength('12345')` will return `5`.
*
* If the length is less than {@link PasswordPolicyChecker.repetitionAndSequenceThreshold},
* `0` will be returned.
*/
/* eslint-disable @silverhand/fp/no-let, @silverhand/fp/no-mutation */
sequenceLength(password: string): number {
const { repetitionAndSequenceThreshold } = PasswordPolicyChecker;
let value = '';
let length = 0;
for (const char of password) {
// Always true
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const charCode = char.codePointAt(0)!;
if (sequence.length === 0) {
sequence.push(charCode);
continue;
}
if (
this.checkSequentialWithChar(sequence, charCode, 1) ||
this.checkSequentialWithChar(sequence, charCode, -1)
) {
sequence.push(charCode);
if (!value || this.isSequential(value + char)) {
value += char;
length += 1;
} else {
sequence = [charCode];
}
if (sequence.length >= threshold) {
return true;
break;
}
}
return false;
return length >= repetitionAndSequenceThreshold ? length : 0;
}
/* eslint-enable @silverhand/fp/no-let, @silverhand/fp/no-mutation, @silverhand/fp/no-mutating-methods */
/* eslint-enable @silverhand/fp/no-let, @silverhand/fp/no-mutation */
/**
* Check if the given char code will be sequential after appending to the given
* char code array.
* Check if the given string is sequential by iterating through the {@link PasswordPolicyChecker.sequence}.
*/
protected checkSequentialWithChar(
current: Readonly<number[]>,
newCharCode: number,
direction: 1 | -1
): boolean {
const lastCharCode = current.at(-1);
protected isSequential(value: string): boolean {
const { sequence } = PasswordPolicyChecker;
const lowercased = value.toLowerCase();
if (newCharCode - direction === lastCharCode) {
if (current.length === 1) {
return true;
}
if (current.at(-2) === newCharCode - direction * 2) {
for (const seq of sequence) {
// eslint-disable-next-line @silverhand/fp/no-mutating-methods -- created a new array before mutating
if (seq.includes(lowercased) || [...seq].reverse().join('').includes(lowercased)) {
return true;
}
}