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'],
|
coverageReporters: ['text-summary', 'lcov'],
|
||||||
coverageProvider: 'v8',
|
coverageProvider: 'v8',
|
||||||
roots: ['./lib'],
|
roots: ['./lib'],
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^(chalk|inquirer)$': '<rootDir>/../../shared/lib/esm/module-proxy.js',
|
'^(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 './scope.js';
|
||||||
export * from './models/index.js';
|
export * from './models/index.js';
|
||||||
export * from './http.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