mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
Merge pull request #4410 from logto-io/gao-init-password-policy-checker
feat(toolkit): init password policy checker
This commit is contained in:
commit
da3cb13c89
5 changed files with 518 additions and 0 deletions
|
@ -5,6 +5,7 @@ const config = {
|
|||
coverageReporters: ['text-summary', 'lcov'],
|
||||
coverageProvider: 'v8',
|
||||
roots: ['./lib'],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
moduleNameMapper: {
|
||||
'^(chalk|inquirer)$': '<rootDir>/../../shared/lib/esm/module-proxy.js',
|
||||
},
|
||||
|
|
4
packages/toolkit/core-kit/jest.setup.js
Normal file
4
packages/toolkit/core-kit/jest.setup.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
import crypto from 'node:crypto';
|
||||
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
global.crypto = crypto;
|
|
@ -3,3 +3,4 @@ export * from './regex.js';
|
|||
export * from './scope.js';
|
||||
export * from './models/index.js';
|
||||
export * from './http.js';
|
||||
export * from './password-policy.js';
|
||||
|
|
195
packages/toolkit/core-kit/src/password-policy.test.ts
Normal file
195
packages/toolkit/core-kit/src/password-policy.test.ts
Normal file
|
@ -0,0 +1,195 @@
|
|||
import { ZodError } from 'zod';
|
||||
|
||||
import { PasswordPolicyChecker } from './password-policy.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
const mockPwnResponse = () => {
|
||||
const originalFetch = global.fetch;
|
||||
beforeAll(() => {
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
// Return hash suffixes for '123456'.
|
||||
text: async () =>
|
||||
'D032E84B0AEB4E773555C73D6B13BEA7A44:1\nD09CA3762AF61E59520943DC26494F8941B:37615252',
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
};
|
||||
|
||||
describe('PasswordPolicyChecker', () => {
|
||||
it('should reject malformed policy', () => {
|
||||
expect(() => {
|
||||
// @ts-expect-error
|
||||
return new PasswordPolicyChecker({ length: { min: 1, max: 2 } });
|
||||
}).toThrowError(ZodError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PasswordPolicyChecker -> check()', () => {
|
||||
mockPwnResponse();
|
||||
|
||||
const checker = new PasswordPolicyChecker({
|
||||
length: { min: 7, max: 8 },
|
||||
characterTypes: { min: 2 },
|
||||
rejects: {
|
||||
pwned: true,
|
||||
repetitionAndSequence: true,
|
||||
words: [{ type: 'custom', value: 'test' }],
|
||||
},
|
||||
});
|
||||
|
||||
it('should accept valid password', async () => {
|
||||
expect(await checker.check('aL1!aL1!')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should reject with all failed checks', async () => {
|
||||
expect(await checker.check('aaa')).toEqual([
|
||||
{ code: 'password_rejected.too_short' },
|
||||
{ code: 'password_rejected.character_types', interpolation: { min: 2 } },
|
||||
{ code: 'password_rejected.repetition' },
|
||||
]);
|
||||
|
||||
expect(await checker.check('123456')).toEqual([
|
||||
{ code: 'password_rejected.too_short' },
|
||||
{ code: 'password_rejected.character_types', interpolation: { min: 2 } },
|
||||
{ code: 'password_rejected.pwned' },
|
||||
{ code: 'password_rejected.sequence' },
|
||||
]);
|
||||
|
||||
expect(await checker.check('aaaaaatest😀')).toEqual([
|
||||
{ code: 'password_rejected.too_long' },
|
||||
{ code: 'password_rejected.unsupported_characters' },
|
||||
{ code: 'password_rejected.repetition' },
|
||||
{
|
||||
code: 'password_rejected.restricted_words',
|
||||
interpolation: { type: 'custom', value: 'test' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PasswordPolicyChecker -> checkCharTypes()', () => {
|
||||
const checker1 = new PasswordPolicyChecker({
|
||||
length: { min: 1, max: 256 },
|
||||
characterTypes: { min: 2 },
|
||||
rejects: { pwned: false, repetitionAndSequence: false, words: [] },
|
||||
});
|
||||
const checker2 = new PasswordPolicyChecker({
|
||||
length: { min: 1, max: 256 },
|
||||
characterTypes: { min: 4 },
|
||||
rejects: { pwned: false, repetitionAndSequence: false, words: [] },
|
||||
});
|
||||
|
||||
it('should reject unsupported characters', () => {
|
||||
expect(checker1.checkCharTypes('😀')).toBe('unsupported');
|
||||
expect(checker2.checkCharTypes('aA1!😀')).toBe('unsupported');
|
||||
});
|
||||
|
||||
it('should reject password with too few character types', () => {
|
||||
expect(checker1.checkCharTypes('a')).toBe(false);
|
||||
expect(checker2.checkCharTypes('aA')).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept password with enough character types', () => {
|
||||
expect(checker1.checkCharTypes('aA')).toBe(true);
|
||||
expect(checker1.checkCharTypes('aA1!0')).toBe(true);
|
||||
expect(checker2.checkCharTypes('aA1!0')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PasswordPolicyChecker -> hasBeenPwned()', () => {
|
||||
const checker = new PasswordPolicyChecker({
|
||||
length: { min: 1, max: 256 },
|
||||
characterTypes: { min: 2 },
|
||||
rejects: { pwned: true, repetitionAndSequence: false, words: [] },
|
||||
});
|
||||
|
||||
mockPwnResponse();
|
||||
|
||||
it('should reject pwned password', async () => {
|
||||
expect(await checker.hasBeenPwned('123456')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept non-pwned password', async () => {
|
||||
expect(await checker.hasBeenPwned('1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PasswordPolicyChecker -> hasRepetition()', () => {
|
||||
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('aL1!bbbbb')).toBe(true);
|
||||
expect(checker.hasRepetition('aaaaaatest😀')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept password with few repeated characters', () => {
|
||||
expect(checker.hasRepetition('aL1!')).toBe(false);
|
||||
expect(checker.hasRepetition('aL1!bbbb')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PasswordPolicyChecker -> hasWords()', () => {
|
||||
const checker = new PasswordPolicyChecker({
|
||||
length: { min: 1, max: 256 },
|
||||
characterTypes: { min: 2 },
|
||||
rejects: {
|
||||
pwned: false,
|
||||
repetitionAndSequence: false,
|
||||
words: [
|
||||
{ type: 'custom', value: 'test' },
|
||||
{ type: 'custom', value: 'teSt2' },
|
||||
{ type: 'personal', value: 'TesT3' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
it('should reject password with blacklisted words (case insensitive)', () => {
|
||||
expect(checker.hasWords('test')).toEqual([{ type: 'custom', value: 'test' }]);
|
||||
expect(checker.hasWords('tEst2')).toEqual([
|
||||
{ type: 'custom', value: 'test' },
|
||||
{ type: 'custom', value: 'test2' },
|
||||
]);
|
||||
expect(checker.hasWords('tEST TEst2 teSt3')).toEqual([
|
||||
{ type: 'custom', value: 'test' },
|
||||
{ type: 'custom', value: 'test2' },
|
||||
{ type: 'personal', value: 'test3' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should accept password without blacklisted words', () => {
|
||||
expect(checker.hasWords('tes4')).toEqual([]);
|
||||
expect(checker.hasWords('tes4 est5 tes t')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PasswordPolicyChecker -> hasSequentialChars()', () => {
|
||||
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 sequential characters', () => {
|
||||
expect(checker.hasSequentialChars('FE')).toBe(true);
|
||||
expect(checker.hasSequentialChars('1234')).toBe(true);
|
||||
expect(checker.hasSequentialChars('edcba')).toBe(true);
|
||||
expect(checker.hasSequentialChars('aL1!BCDEFA')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept password with few sequential characters', () => {
|
||||
expect(checker.hasSequentialChars('z')).toBe(false);
|
||||
expect(checker.hasSequentialChars('aL1!')).toBe(false);
|
||||
expect(checker.hasSequentialChars('aL1!bcdedcb1234321')).toBe(false);
|
||||
});
|
||||
});
|
317
packages/toolkit/core-kit/src/password-policy.ts
Normal file
317
packages/toolkit/core-kit/src/password-policy.ts
Normal file
|
@ -0,0 +1,317 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/** A word that used for password policy. */
|
||||
type Word = {
|
||||
type: 'custom' | 'personal';
|
||||
value: string;
|
||||
};
|
||||
|
||||
/** Password policy configuration type. */
|
||||
type PasswordPolicy = {
|
||||
/** Policy about password length. */
|
||||
length: {
|
||||
/** Minimum password length. */
|
||||
min: number;
|
||||
/** Maximum password length. */
|
||||
max: number;
|
||||
};
|
||||
/**
|
||||
* Policy about password character types. Four types of characters are supported:
|
||||
*
|
||||
* - Lowercase letters (a-z).
|
||||
* - Uppercase letters (A-Z).
|
||||
* - Digits (0-9).
|
||||
* - Symbols ({@link PasswordPolicyChecker.symbols}).
|
||||
*/
|
||||
characterTypes: {
|
||||
/** Minimum number of character types. Range: 1-4. */
|
||||
min: number;
|
||||
};
|
||||
/** Policy about what passwords to reject. */
|
||||
rejects: {
|
||||
/** Whether to reject passwords that are pwned. */
|
||||
pwned: boolean;
|
||||
/** Whether to reject passwords that like '123456' or 'aaaaaa'. */
|
||||
repetitionAndSequence: boolean;
|
||||
/** Whether to reject passwords that include specific words. */
|
||||
words: Word[];
|
||||
};
|
||||
};
|
||||
|
||||
/** Password policy configuration guard. */
|
||||
const passwordPolicyGuard: z.ZodType<PasswordPolicy> = z.object({
|
||||
length: z.object({
|
||||
min: z.number().int().min(1),
|
||||
max: z.number().int().min(1),
|
||||
}),
|
||||
characterTypes: z.object({
|
||||
min: z.number().int().min(1).max(4),
|
||||
}),
|
||||
rejects: z.object({
|
||||
pwned: z.boolean(),
|
||||
repetitionAndSequence: z.boolean(),
|
||||
words: z.array(z.object({ type: z.enum(['custom', 'personal']), value: z.string() })),
|
||||
}),
|
||||
});
|
||||
|
||||
/** The code of why a password is rejected. */
|
||||
type PasswordRejectionCode =
|
||||
| 'too_short'
|
||||
| 'too_long'
|
||||
| 'character_types'
|
||||
| 'unsupported_characters'
|
||||
| 'pwned'
|
||||
| 'repetition'
|
||||
| 'sequence'
|
||||
| 'restricted_words';
|
||||
|
||||
/** A password issue that does not meet the policy. */
|
||||
type PasswordIssue = {
|
||||
/** Issue code. */
|
||||
code: `password_rejected.${PasswordRejectionCode}`;
|
||||
/** Interpolation data for the issue message. */
|
||||
interpolation?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The class for checking if a password meets the policy. The policy is defined as
|
||||
* {@link PasswordPolicy}.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const checker = new PasswordPolicyChecker({
|
||||
* length: { min: 8, max: 256 },
|
||||
* characterTypes: { min: 2 },
|
||||
* rejects: { pwned: true, repetitionAndSequence: true, words: [] },
|
||||
* });
|
||||
*
|
||||
* const issues = await checker.check('123456');
|
||||
* console.log(issues);
|
||||
* // [
|
||||
* // { code: 'password_rejected.too_short' },
|
||||
* // { code: 'password_rejected.character_types', interpolation: { min: 2 } },
|
||||
* // { code: 'password_rejected.pwned' },
|
||||
* // { code: 'password_rejected.sequence' },
|
||||
* // ]
|
||||
* ```
|
||||
*/
|
||||
export class PasswordPolicyChecker {
|
||||
static symbols = Object.freeze('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' as const);
|
||||
|
||||
constructor(public readonly policy: PasswordPolicy) {
|
||||
// Validate policy.
|
||||
passwordPolicyGuard.parse(policy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a password meets the policy.
|
||||
*
|
||||
* @param password - Password to check.
|
||||
* @returns An array of issues. If the password meets the policy, an empty array will be returned.
|
||||
*/
|
||||
/* eslint-disable @silverhand/fp/no-mutating-methods */
|
||||
async check(password: string): Promise<PasswordIssue[]> {
|
||||
const issues: PasswordIssue[] = [];
|
||||
|
||||
if (password.length < this.policy.length.min) {
|
||||
issues.push({
|
||||
code: 'password_rejected.too_short',
|
||||
});
|
||||
} else if (password.length > this.policy.length.max) {
|
||||
issues.push({
|
||||
code: 'password_rejected.too_long',
|
||||
});
|
||||
}
|
||||
|
||||
const characterTypes = this.checkCharTypes(password);
|
||||
if (characterTypes === 'unsupported') {
|
||||
issues.push({
|
||||
code: 'password_rejected.unsupported_characters',
|
||||
});
|
||||
} else if (!characterTypes) {
|
||||
issues.push({
|
||||
code: 'password_rejected.character_types',
|
||||
interpolation: { min: this.policy.characterTypes.min },
|
||||
});
|
||||
}
|
||||
|
||||
if (this.policy.rejects.pwned && (await this.hasBeenPwned(password))) {
|
||||
issues.push({
|
||||
code: 'password_rejected.pwned',
|
||||
});
|
||||
}
|
||||
|
||||
if (this.policy.rejects.repetitionAndSequence) {
|
||||
if (this.hasRepetition(password)) {
|
||||
issues.push({
|
||||
code: 'password_rejected.repetition',
|
||||
});
|
||||
}
|
||||
|
||||
if (this.hasSequentialChars(password)) {
|
||||
issues.push({
|
||||
code: 'password_rejected.sequence',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
issues.push(
|
||||
...this.hasWords(password).map<PasswordIssue>(({ type, value }) => ({
|
||||
code: 'password_rejected.restricted_words',
|
||||
interpolation: { type, value },
|
||||
}))
|
||||
);
|
||||
|
||||
return issues;
|
||||
}
|
||||
/* eslint-enable @silverhand/fp/no-mutating-methods */
|
||||
|
||||
/**
|
||||
* Check if the given password contains enough character types.
|
||||
*
|
||||
* @param password - Password to check.
|
||||
* @returns Whether the password contains enough character types; or `'unsupported'`
|
||||
* if the password contains unsupported characters.
|
||||
*/
|
||||
checkCharTypes(password: string): boolean | 'unsupported' {
|
||||
const characterTypes = new Set<string>();
|
||||
for (const char of password) {
|
||||
if (char >= 'a' && char <= 'z') {
|
||||
characterTypes.add('lowercase');
|
||||
} else if (char >= 'A' && char <= 'Z') {
|
||||
characterTypes.add('uppercase');
|
||||
} else if (char >= '0' && char <= '9') {
|
||||
characterTypes.add('digits');
|
||||
} else if (PasswordPolicyChecker.symbols.includes(char)) {
|
||||
characterTypes.add('symbols');
|
||||
} else {
|
||||
return 'unsupported';
|
||||
}
|
||||
}
|
||||
|
||||
return characterTypes.size >= this.policy.characterTypes.min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given password has been pwned.
|
||||
*
|
||||
* @param password - Password to check.
|
||||
* @returns Whether the password has been pwned.
|
||||
*/
|
||||
async hasBeenPwned(password: string): Promise<boolean> {
|
||||
const hash = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(password));
|
||||
const hashHex = Array.from(new Uint8Array(hash))
|
||||
.map((binary) => binary.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
const hashPrefix = hashHex.slice(0, 5);
|
||||
const hashSuffix = hashHex.slice(5);
|
||||
const response = await fetch(`https://api.haveibeenpwned.com/range/${hashPrefix}`);
|
||||
const text = await response.text();
|
||||
const hashes = text.split('\n');
|
||||
const found = hashes.some((hex) => hex.toLowerCase().startsWith(hashSuffix));
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given password contains repetition characters that are more than
|
||||
* 4 characters or equal to the password length.
|
||||
*
|
||||
* @param password - Password to check.
|
||||
* @returns Whether the password contains repetition characters.
|
||||
*/
|
||||
hasRepetition(password: string): boolean {
|
||||
const matchedRepetition = password.match(
|
||||
new RegExp(`(.)\\1{${Math.min(password.length - 1, 4)},}`, 'g')
|
||||
);
|
||||
|
||||
return Boolean(matchedRepetition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given password contains specific words.
|
||||
*
|
||||
* @param password - Password to check.
|
||||
* @returns An array of matched words.
|
||||
*/
|
||||
hasWords(password: string): Word[] {
|
||||
const words = this.policy.rejects.words.map(({ value, ...rest }) => ({
|
||||
...rest,
|
||||
value: value.toLowerCase(),
|
||||
}));
|
||||
const lowercasedPassword = password.toLowerCase();
|
||||
|
||||
return words.filter(({ value }) => lowercasedPassword.includes(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given password contains sequential characters, and the number of
|
||||
* sequential characters is over 4 or equal to the password length when (1 < length < 5).
|
||||
*
|
||||
* @param password - Password to check.
|
||||
* @returns Whether the password contains sequential characters.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* hasSequentialChars('1'); // 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 {
|
||||
let sequence: number[] = [];
|
||||
|
||||
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);
|
||||
} else {
|
||||
sequence = [charCode];
|
||||
}
|
||||
|
||||
if (sequence.length >= 5 || sequence.length === password.length) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
/* eslint-enable @silverhand/fp/no-let, @silverhand/fp/no-mutation, @silverhand/fp/no-mutating-methods */
|
||||
|
||||
/**
|
||||
* Check if the given char code will be sequential after appending to the given
|
||||
* char code array.
|
||||
*/
|
||||
protected checkSequentialWithChar(
|
||||
current: Readonly<number[]>,
|
||||
newCharCode: number,
|
||||
direction: 1 | -1
|
||||
): boolean {
|
||||
const lastCharCode = current.at(-1);
|
||||
|
||||
if (newCharCode - direction === lastCharCode) {
|
||||
if (current.length === 1) {
|
||||
return true;
|
||||
}
|
||||
if (current.at(-2) === newCharCode - direction * 2) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue