diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index a6aff9e455b..39b772221a4 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -268,6 +268,9 @@ "length": { "message": "Length" }, + "passwordMinLength": { + "message": "Minimum password length" + }, "uppercase": { "message": "Uppercase (A-Z)" }, diff --git a/apps/browser/src/popup/scss/box.scss b/apps/browser/src/popup/scss/box.scss index d98c29176da..179673279e6 100644 --- a/apps/browser/src/popup/scss/box.scss +++ b/apps/browser/src/popup/scss/box.scss @@ -341,7 +341,8 @@ } } - .img-right { + .img-right, + .txt-right { float: right; margin-left: 10px; } diff --git a/apps/browser/src/tools/popup/generator/generator.component.html b/apps/browser/src/tools/popup/generator/generator.component.html index 5f722b661ce..3e6dfe0747f 100644 --- a/apps/browser/src/tools/popup/generator/generator.component.html +++ b/apps/browser/src/tools/popup/generator/generator.component.html @@ -176,7 +176,7 @@ +
+ {{ "passwordMinLength" | i18n }} + + {{ passwordOptionsMinLengthForReader$ | async }} + + {{ passwordOptions.minLength }} +
@@ -232,10 +244,10 @@
@@ -249,8 +261,8 @@ type="number" min="0" max="9" - (change)="savePasswordOptions()" [(ngModel)]="passwordOptions.minNumber" + (input)="onPasswordOptionsMinNumberInput($event)" />
@@ -260,8 +272,8 @@ type="number" min="0" max="9" - (change)="savePasswordOptions()" [(ngModel)]="passwordOptions.minSpecial" + (input)="onPasswordOptionsMinSpecialInput($event)" />
diff --git a/apps/desktop/src/app/tools/generator.component.html b/apps/desktop/src/app/tools/generator.component.html index 0c66ebde805..aa77aafc146 100644 --- a/apps/desktop/src/app/tools/generator.component.html +++ b/apps/desktop/src/app/tools/generator.component.html @@ -200,7 +200,7 @@
+
+ {{ "passwordMinLength" | i18n }} + {{ passwordOptions.minLength }} + + {{ passwordOptionsMinLengthForReader$ | async }} + +
@@ -258,7 +271,8 @@ type="checkbox" (change)="savePasswordOptions()" [disabled]="enforcedPasswordPolicyOptions?.useSpecial" - [(ngModel)]="passwordOptions.special" + [ngModel]="passwordOptions.special" + (ngModelChange)="setPasswordOptionsSpecial($event)" attr.aria-label="{{ 'specialCharacters' | i18n }}" /> @@ -275,6 +289,7 @@ max="9" (change)="savePasswordOptions()" [(ngModel)]="passwordOptions.minNumber" + (input)="onPasswordOptionsMinNumberInput($event)" />
@@ -286,6 +301,7 @@ max="9" (change)="savePasswordOptions()" [(ngModel)]="passwordOptions.minSpecial" + (input)="onPasswordOptionsMinSpecialInput($event)" />
diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 28051428e7d..ca7fdf4d603 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -403,6 +403,9 @@ "length": { "message": "Length" }, + "passwordMinLength": { + "message": "Minimum password length" + }, "uppercase": { "message": "Uppercase (A-Z)" }, diff --git a/apps/desktop/src/scss/box.scss b/apps/desktop/src/scss/box.scss index 9466c89a7ff..0e89e9fd74e 100644 --- a/apps/desktop/src/scss/box.scss +++ b/apps/desktop/src/scss/box.scss @@ -217,7 +217,8 @@ } } - .img-right { + .img-right, + .txt-right { float: right; margin-left: 10px; } diff --git a/apps/web/src/app/tools/generator.component.html b/apps/web/src/app/tools/generator.component.html index 2e6d6d0effd..3a079bcac5a 100644 --- a/apps/web/src/app/tools/generator.component.html +++ b/apps/web/src/app/tools/generator.component.html @@ -109,13 +109,31 @@ id="length" class="form-control" type="number" - min="5" + [min]="passwordOptions.minLength" max="128" [(ngModel)]="passwordOptions.length" (blur)="savePasswordOptions()" (change)="lengthChanged()" />
+
+ + + + {{ passwordOptionsMinLengthForReader$ | async }} + +
@@ -137,8 +155,8 @@ type="number" min="0" max="9" - (blur)="savePasswordOptions()" [(ngModel)]="passwordOptions.minSpecial" + (input)="onPasswordOptionsMinSpecialInput($event)" (change)="minSpecialChanged()" /> @@ -175,7 +193,8 @@ class="form-check-input" type="checkbox" (change)="savePasswordOptions()" - [(ngModel)]="passwordOptions.number" + [ngModel]="passwordOptions.number" + (ngModelChange)="setPasswordOptionsNumber($event)" [disabled]="enforcedPasswordPolicyOptions?.useNumbers" attr.aria-label="{{ 'numbers' | i18n }}" /> @@ -186,8 +205,8 @@ id="special" class="form-check-input" type="checkbox" - (change)="savePasswordOptions()" - [(ngModel)]="passwordOptions.special" + [ngModel]="passwordOptions.special" + (ngModelChange)="setPasswordOptionsSpecial($event)" [disabled]="enforcedPasswordPolicyOptions?.useSpecial" attr.aria-label="{{ 'specialCharacters' | i18n }}" /> diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 236ba76cfe2..0ec3b27cc38 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1150,6 +1150,9 @@ "length": { "message": "Length" }, + "passwordMinLength": { + "message": "Minimum password length" + }, "uppercase": { "message": "Uppercase (A-Z)", "description": "Include uppercase letters in the password generator." diff --git a/libs/angular/src/tools/generator/components/generator.component.ts b/libs/angular/src/tools/generator/components/generator.component.ts index 36af4868b0c..35ec0b2c5a2 100644 --- a/libs/angular/src/tools/generator/components/generator.component.ts +++ b/libs/angular/src/tools/generator/components/generator.component.ts @@ -1,6 +1,7 @@ import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { first } from "rxjs/operators"; +import { BehaviorSubject } from "rxjs"; +import { debounceTime, first, map } from "rxjs/operators"; import { PasswordGeneratorPolicyOptions } from "@bitwarden/common/admin-console/models/domain/password-generator-policy-options"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -12,6 +13,7 @@ import { PasswordGenerationServiceAbstraction, PasswordGeneratorOptions, } from "@bitwarden/common/tools/generator/password"; +import { DefaultBoundaries } from "@bitwarden/common/tools/generator/password/password-generator-options-evaluator"; import { UsernameGenerationServiceAbstraction, UsernameGeneratorOptions, @@ -40,6 +42,16 @@ export class GeneratorComponent implements OnInit { enforcedPasswordPolicyOptions: PasswordGeneratorPolicyOptions; usernameWebsite: string = null; + // update screen reader minimum password length with 500ms debounce + // so that the user isn't flooded with status updates + private _passwordOptionsMinLengthForReader = new BehaviorSubject( + DefaultBoundaries.length.min, + ); + protected passwordOptionsMinLengthForReader$ = this._passwordOptionsMinLengthForReader.pipe( + map((val) => val || DefaultBoundaries.length.min), + debounceTime(500), + ); + constructor( protected passwordGenerationService: PasswordGenerationServiceAbstraction, protected usernameGenerationService: UsernameGenerationServiceAbstraction, @@ -144,6 +156,44 @@ export class GeneratorComponent implements OnInit { await this.passwordGenerationService.addHistory(this.password); } + async onPasswordOptionsMinNumberInput($event: Event) { + // `savePasswordOptions()` replaces the null + this.passwordOptions.number = null; + + await this.savePasswordOptions(); + + // fixes UI desync that occurs when minNumber has a fixed value + // that is reset through normalization + ($event.target as HTMLInputElement).value = `${this.passwordOptions.minNumber}`; + } + + async setPasswordOptionsNumber($event: boolean) { + this.passwordOptions.number = $event; + // `savePasswordOptions()` replaces the null + this.passwordOptions.minNumber = null; + + await this.savePasswordOptions(); + } + + async onPasswordOptionsMinSpecialInput($event: Event) { + // `savePasswordOptions()` replaces the null + this.passwordOptions.special = null; + + await this.savePasswordOptions(); + + // fixes UI desync that occurs when minSpecial has a fixed value + // that is reset through normalization + ($event.target as HTMLInputElement).value = `${this.passwordOptions.minSpecial}`; + } + + async setPasswordOptionsSpecial($event: boolean) { + this.passwordOptions.special = $event; + // `savePasswordOptions()` replaces the null + this.passwordOptions.minSpecial = null; + + await this.savePasswordOptions(); + } + async sliderInput() { this.normalizePasswordOptions(); this.password = await this.passwordGenerationService.generatePassword(this.passwordOptions); @@ -240,6 +290,8 @@ export class GeneratorComponent implements OnInit { this.passwordOptions, this.enforcedPasswordPolicyOptions, ); + + this._passwordOptionsMinLengthForReader.next(this.passwordOptions.minLength); } private async initForwardOptions() { diff --git a/libs/common/src/admin-console/models/domain/password-generator-policy-options.ts b/libs/common/src/admin-console/models/domain/password-generator-policy-options.ts index 858b9fd8829..9d2e7eadd59 100644 --- a/libs/common/src/admin-console/models/domain/password-generator-policy-options.ts +++ b/libs/common/src/admin-console/models/domain/password-generator-policy-options.ts @@ -1,18 +1,73 @@ import Domain from "../../../platform/models/domain/domain-base"; +/** Enterprise policy for the password generator. + * @see PolicyType.PasswordGenerator + */ export class PasswordGeneratorPolicyOptions extends Domain { - defaultType = ""; + /** The default kind of credential to generate */ + defaultType: "password" | "passphrase" | "" = ""; + + /** The minimum length of generated passwords. + * When this is less than or equal to zero, it is ignored. + * If this is less than the total number of characters required by + * the policy's other settings, then it is ignored. + * This field is not used for passphrases. + */ minLength = 0; + + /** When this is true, an uppercase character must be part of + * the generated password. + * This field is not used for passphrases. + */ useUppercase = false; + + /** When this is true, a lowercase character must be part of + * the generated password. This field is not used for passphrases. + */ useLowercase = false; + + /** When this is true, at least one digit must be part of the generated + * password. This field is not used for passphrases. + */ useNumbers = false; + + /** The quantity of digits to include in the generated password. + * When this is less than or equal to zero, it is ignored. + * This field is not used for passphrases. + */ numberCount = 0; + + /** When this is true, at least one digit must be part of the generated + * password. This field is not used for passphrases. + */ useSpecial = false; + + /** The quantity of special characters to include in the generated + * password. When this is less than or equal to zero, it is ignored. + * This field is not used for passphrases. + */ specialCount = 0; + + /** The minimum number of words required by generated passphrases. + * This field is not used for passwords. + */ minNumberWords = 0; + + /** When this is true, the first letter of each word in the passphrase + * is capitalized. This field is not used for passwords. + */ capitalize = false; + + /** When this is true, a number is included within the passphrase. + * This field is not used for passwords. + */ includeNumber = false; + /** Checks whether the policy affects the password generator. + * @returns True if at least one password or passphrase requirement has been set. + * If it returns False, then no requirements have been set and the policy should + * not be enforced. + */ inEffect() { return ( this.defaultType !== "" || @@ -28,4 +83,12 @@ export class PasswordGeneratorPolicyOptions extends Domain { this.includeNumber ); } + + /** Creates a copy of the policy. + */ + clone() { + const policy = new PasswordGeneratorPolicyOptions(); + Object.assign(policy, this); + return policy; + } } diff --git a/libs/common/src/tools/generator/password/passphrase-generator-options-evaluator.spec.ts b/libs/common/src/tools/generator/password/passphrase-generator-options-evaluator.spec.ts new file mode 100644 index 00000000000..c72a190f799 --- /dev/null +++ b/libs/common/src/tools/generator/password/passphrase-generator-options-evaluator.spec.ts @@ -0,0 +1,220 @@ +import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options"; + +import { + DefaultBoundaries, + PassphraseGeneratorOptionsEvaluator, +} from "./passphrase-generator-options-evaluator"; +import { PassphraseGenerationOptions } from "./password-generator-options"; + +describe("Password generator options builder", () => { + describe("constructor()", () => { + it("should set the policy object to a copy of the input policy", () => { + const policy = new PasswordGeneratorPolicyOptions(); + policy.minLength = 10; // arbitrary change for deep equality check + + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + + expect(builder.policy).toEqual(policy); + expect(builder.policy).not.toBe(policy); + }); + + it("should set default boundaries when a default policy is used", () => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + + expect(builder.numWords).toEqual(DefaultBoundaries.numWords); + }); + + it.each([1, 2])( + "should use the default word boundaries when they are greater than `policy.minNumberWords` (= %i)", + (minNumberWords) => { + const policy = new PasswordGeneratorPolicyOptions(); + policy.minNumberWords = minNumberWords; + + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + + expect(builder.numWords).toEqual(DefaultBoundaries.numWords); + }, + ); + + it.each([8, 12, 18])( + "should use `policy.minNumberWords` (= %i) when it is greater than the default minimum words", + (minNumberWords) => { + const policy = new PasswordGeneratorPolicyOptions(); + policy.minNumberWords = minNumberWords; + + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + + expect(builder.numWords.min).toEqual(minNumberWords); + expect(builder.numWords.max).toEqual(DefaultBoundaries.numWords.max); + }, + ); + + it.each([150, 300, 9000])( + "should use `policy.minNumberWords` (= %i) when it is greater than the default boundaries", + (minNumberWords) => { + const policy = new PasswordGeneratorPolicyOptions(); + policy.minNumberWords = minNumberWords; + + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + + expect(builder.numWords.min).toEqual(minNumberWords); + expect(builder.numWords.max).toEqual(minNumberWords); + }, + ); + }); + + describe("applyPolicy(options)", () => { + // All tests should freeze the options to ensure they are not modified + + it("should set `capitalize` to `false` when the policy does not override it", () => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({}); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.capitalize).toBe(false); + }); + + it("should set `capitalize` to `true` when the policy overrides it", () => { + const policy = new PasswordGeneratorPolicyOptions(); + policy.capitalize = true; + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ capitalize: false }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.capitalize).toBe(true); + }); + + it("should set `includeNumber` to false when the policy does not override it", () => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({}); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.includeNumber).toBe(false); + }); + + it("should set `includeNumber` to true when the policy overrides it", () => { + const policy = new PasswordGeneratorPolicyOptions(); + policy.includeNumber = true; + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ includeNumber: false }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.includeNumber).toBe(true); + }); + + it("should set `numWords` to the minimum value when it isn't supplied", () => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({}); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.numWords).toBe(builder.numWords.min); + }); + + it.each([1, 2])( + "should set `numWords` (= %i) to the minimum value when it is less than the minimum", + (numWords) => { + expect(numWords).toBeLessThan(DefaultBoundaries.numWords.min); + + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ numWords }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.numWords).toBe(builder.numWords.min); + }, + ); + + it.each([3, 8, 18, 20])( + "should set `numWords` (= %i) to the input value when it is within the boundaries", + (numWords) => { + expect(numWords).toBeGreaterThanOrEqual(DefaultBoundaries.numWords.min); + expect(numWords).toBeLessThanOrEqual(DefaultBoundaries.numWords.max); + + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ numWords }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.numWords).toBe(numWords); + }, + ); + + it.each([21, 30, 50, 100])( + "should set `numWords` (= %i) to the maximum value when it is greater than the maximum", + (numWords) => { + expect(numWords).toBeGreaterThan(DefaultBoundaries.numWords.max); + + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ numWords }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.numWords).toBe(builder.numWords.max); + }, + ); + + it("should preserve unknown properties", () => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ + unknown: "property", + another: "unknown property", + }) as PassphraseGenerationOptions; + + const sanitizedOptions: any = builder.applyPolicy(options); + + expect(sanitizedOptions.unknown).toEqual("property"); + expect(sanitizedOptions.another).toEqual("unknown property"); + }); + }); + + describe("sanitize(options)", () => { + // All tests should freeze the options to ensure they are not modified + + it("should return the input options without altering them", () => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ wordSeparator: "%" }); + + const sanitizedOptions = builder.sanitize(options); + + expect(sanitizedOptions).toEqual(options); + }); + + it("should set `wordSeparator` to '-' when it isn't supplied and there is no policy override", () => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({}); + + const sanitizedOptions = builder.sanitize(options); + + expect(sanitizedOptions.wordSeparator).toEqual("-"); + }); + + it("should preserve unknown properties", () => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ + unknown: "property", + another: "unknown property", + }) as PassphraseGenerationOptions; + + const sanitizedOptions: any = builder.sanitize(options); + + expect(sanitizedOptions.unknown).toEqual("property"); + expect(sanitizedOptions.another).toEqual("unknown property"); + }); + }); +}); diff --git a/libs/common/src/tools/generator/password/passphrase-generator-options-evaluator.ts b/libs/common/src/tools/generator/password/passphrase-generator-options-evaluator.ts new file mode 100644 index 00000000000..8cfdce4b094 --- /dev/null +++ b/libs/common/src/tools/generator/password/passphrase-generator-options-evaluator.ts @@ -0,0 +1,105 @@ +import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options"; + +import { PassphraseGenerationOptions } from "./password-generator-options"; + +type Boundary = { + readonly min: number; + readonly max: number; +}; + +function initializeBoundaries() { + const numWords = Object.freeze({ + min: 3, + max: 20, + }); + + return Object.freeze({ + numWords, + }); +} + +/** Immutable default boundaries for passphrase generation. + * These are used when the policy does not override a value. + */ +export const DefaultBoundaries = initializeBoundaries(); + +/** Enforces policy for passphrase generation options. + */ +export class PassphraseGeneratorOptionsEvaluator { + // This design is not ideal, but it is a step towards a more robust passphrase + // generator. Ideally, `sanitize` would be implemented on an options class, + // and `applyPolicy` would be implemented on a policy class, "mise en place". + // + // The current design of the passphrase generator, unfortunately, would require + // a substantial rewrite to make this feasible. Hopefully this change can be + // applied when the passphrase generator is ported to rust. + + /** Policy applied by the evaluator. + */ + readonly policy: PasswordGeneratorPolicyOptions; + + /** Boundaries for the number of words allowed in the password. + */ + readonly numWords: Boundary; + + /** Instantiates the evaluator. + * @param policy The policy applied by the evaluator. When this conflicts with + * the defaults, the policy takes precedence. + */ + constructor(policy: PasswordGeneratorPolicyOptions) { + function createBoundary(value: number, defaultBoundary: Boundary): Boundary { + const boundary = { + min: Math.max(defaultBoundary.min, value), + max: Math.max(defaultBoundary.max, value), + }; + + return boundary; + } + + this.policy = policy.clone(); + this.numWords = createBoundary(policy.minNumberWords, DefaultBoundaries.numWords); + } + + /** Apply policy to the input options. + * @param options The options to build from. These options are not altered. + * @returns A new password generation request with policy applied. + */ + applyPolicy(options: PassphraseGenerationOptions): PassphraseGenerationOptions { + function fitToBounds(value: number, boundaries: Boundary) { + const { min, max } = boundaries; + + const withUpperBound = Math.min(value ?? boundaries.min, max); + const withLowerBound = Math.max(withUpperBound, min); + + return withLowerBound; + } + + // apply policy overrides + const capitalize = this.policy.capitalize || options.capitalize || false; + const includeNumber = this.policy.includeNumber || options.includeNumber || false; + + // apply boundaries + const numWords = fitToBounds(options.numWords, this.numWords); + + return { + ...options, + numWords, + capitalize, + includeNumber, + }; + } + + /** Ensures internal options consistency. + * @param options The options to cascade. These options are not altered. + * @returns A passphrase generation request with cascade applied. + */ + sanitize(options: PassphraseGenerationOptions): PassphraseGenerationOptions { + // ensure words are separated by a single character + const wordSeparator = options.wordSeparator?.[0] ?? "-"; + + return { + ...options, + wordSeparator, + }; + } +} diff --git a/libs/common/src/tools/generator/password/password-generation.service.ts b/libs/common/src/tools/generator/password/password-generation.service.ts index aa77502f758..e497126cd30 100644 --- a/libs/common/src/tools/generator/password/password-generation.service.ts +++ b/libs/common/src/tools/generator/password/password-generation.service.ts @@ -7,11 +7,14 @@ import { EFFLongWordList } from "../../../platform/misc/wordlist"; import { EncString } from "../../../platform/models/domain/enc-string"; import { GeneratedPasswordHistory } from "./generated-password-history"; +import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator"; import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction"; import { PasswordGeneratorOptions } from "./password-generator-options"; +import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator"; const DefaultOptions: PasswordGeneratorOptions = { length: 14, + minLength: 5, ambiguous: false, number: true, minNumber: 1, @@ -28,6 +31,8 @@ const DefaultOptions: PasswordGeneratorOptions = { includeNumber: false, }; +const DefaultPolicy = new PasswordGeneratorPolicyOptions(); + const MaxPasswordsInHistory = 100; export class PasswordGenerationService implements PasswordGenerationServiceAbstraction { @@ -38,20 +43,12 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr ) {} async generatePassword(options: PasswordGeneratorOptions): Promise { - // overload defaults with given options - const o = Object.assign({}, DefaultOptions, options); - - if (o.type === "passphrase") { - return this.generatePassphrase(options); + if ((options.type ?? DefaultOptions.type) === "passphrase") { + return this.generatePassphrase({ ...DefaultOptions, ...options }); } - // sanitize - this.sanitizePasswordLength(o, true); - - const minLength: number = o.minUppercase + o.minLowercase + o.minNumber + o.minSpecial; - if (o.length < minLength) { - o.length = minLength; - } + const evaluator = new PasswordGeneratorOptionsEvaluator(DefaultPolicy); + const o = evaluator.sanitize({ ...DefaultOptions, ...options }); const positions: string[] = []; if (o.lowercase && o.minLowercase > 0) { @@ -144,7 +141,8 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr } async generatePassphrase(options: PasswordGeneratorOptions): Promise { - const o = Object.assign({}, DefaultOptions, options); + const evaluator = new PassphraseGeneratorOptionsEvaluator(DefaultPolicy); + const o = evaluator.sanitize({ ...DefaultOptions, ...options }); if (o.numWords == null || o.numWords <= 2) { o.numWords = DefaultOptions.numWords; @@ -192,65 +190,25 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr async enforcePasswordGeneratorPoliciesOnOptions( options: PasswordGeneratorOptions, ): Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]> { - let enforcedPolicyOptions = await this.getPasswordGeneratorPolicyOptions(); - if (enforcedPolicyOptions != null) { - if (options.length < enforcedPolicyOptions.minLength) { - options.length = enforcedPolicyOptions.minLength; - } + let policy = await this.getPasswordGeneratorPolicyOptions(); + policy = policy ?? new PasswordGeneratorPolicyOptions(); - if (enforcedPolicyOptions.useUppercase) { - options.uppercase = true; - } - - if (enforcedPolicyOptions.useLowercase) { - options.lowercase = true; - } - - if (enforcedPolicyOptions.useNumbers) { - options.number = true; - } - - if (options.minNumber < enforcedPolicyOptions.numberCount) { - options.minNumber = enforcedPolicyOptions.numberCount; - } - - if (enforcedPolicyOptions.useSpecial) { - options.special = true; - } - - if (options.minSpecial < enforcedPolicyOptions.specialCount) { - options.minSpecial = enforcedPolicyOptions.specialCount; - } - - // Must normalize these fields because the receiving call expects all options to pass the current rules - if (options.minSpecial + options.minNumber > options.length) { - options.minSpecial = options.length - options.minNumber; - } - - if (options.numWords < enforcedPolicyOptions.minNumberWords) { - options.numWords = enforcedPolicyOptions.minNumberWords; - } - - if (enforcedPolicyOptions.capitalize) { - options.capitalize = true; - } - - if (enforcedPolicyOptions.includeNumber) { - options.includeNumber = true; - } - - // Force default type if password/passphrase selected via policy - if ( - enforcedPolicyOptions.defaultType === "password" || - enforcedPolicyOptions.defaultType === "passphrase" - ) { - options.type = enforcedPolicyOptions.defaultType; - } - } else { - // UI layer expects an instantiated object to prevent more explicit null checks - enforcedPolicyOptions = new PasswordGeneratorPolicyOptions(); + // Force default type if password/passphrase selected via policy + if (policy.defaultType === "password" || policy.defaultType === "passphrase") { + options.type = policy.defaultType; } - return [options, enforcedPolicyOptions]; + + const evaluator = options.type + ? new PasswordGeneratorOptionsEvaluator(policy) + : new PassphraseGeneratorOptionsEvaluator(policy); + + // Ensure the options to pass the current rules + const withPolicy = evaluator.applyPolicy(options); + const sanitized = evaluator.sanitize(withPolicy); + + // callers assume this function updates the options parameter + const result = Object.assign(options, sanitized); + return [result, policy]; } async getPasswordGeneratorPolicyOptions(): Promise { @@ -389,62 +347,17 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr options: PasswordGeneratorOptions, enforcedPolicyOptions: PasswordGeneratorPolicyOptions, ) { - options.minLowercase = 0; - options.minUppercase = 0; + const evaluator = options.type + ? new PasswordGeneratorOptionsEvaluator(enforcedPolicyOptions) + : new PassphraseGeneratorOptionsEvaluator(enforcedPolicyOptions); - if (!options.length || options.length < 5) { - options.length = 5; - } else if (options.length > 128) { - options.length = 128; - } + const evaluatedOptions = evaluator.applyPolicy(options); + const santizedOptions = evaluator.sanitize(evaluatedOptions); - if (options.length < enforcedPolicyOptions.minLength) { - options.length = enforcedPolicyOptions.minLength; - } + // callers assume this function updates the options parameter + Object.assign(options, santizedOptions); - if (!options.minNumber) { - options.minNumber = 0; - } else if (options.minNumber > options.length) { - options.minNumber = options.length; - } else if (options.minNumber > 9) { - options.minNumber = 9; - } - - if (options.minNumber < enforcedPolicyOptions.numberCount) { - options.minNumber = enforcedPolicyOptions.numberCount; - } - - if (!options.minSpecial) { - options.minSpecial = 0; - } else if (options.minSpecial > options.length) { - options.minSpecial = options.length; - } else if (options.minSpecial > 9) { - options.minSpecial = 9; - } - - if (options.minSpecial < enforcedPolicyOptions.specialCount) { - options.minSpecial = enforcedPolicyOptions.specialCount; - } - - if (options.minSpecial + options.minNumber > options.length) { - options.minSpecial = options.length - options.minNumber; - } - - if (options.numWords == null || options.length < 3) { - options.numWords = 3; - } else if (options.numWords > 20) { - options.numWords = 20; - } - - if (options.numWords < enforcedPolicyOptions.minNumberWords) { - options.numWords = enforcedPolicyOptions.minNumberWords; - } - - if (options.wordSeparator != null && options.wordSeparator.length > 1) { - options.wordSeparator = options.wordSeparator[0]; - } - - this.sanitizePasswordLength(options, false); + return options; } private capitalize(str: string) { @@ -505,54 +418,4 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr [array[i], array[j]] = [array[j], array[i]]; } } - - private sanitizePasswordLength(options: any, forGeneration: boolean) { - let minUppercaseCalc = 0; - let minLowercaseCalc = 0; - let minNumberCalc: number = options.minNumber; - let minSpecialCalc: number = options.minSpecial; - - if (options.uppercase && options.minUppercase <= 0) { - minUppercaseCalc = 1; - } else if (!options.uppercase) { - minUppercaseCalc = 0; - } - - if (options.lowercase && options.minLowercase <= 0) { - minLowercaseCalc = 1; - } else if (!options.lowercase) { - minLowercaseCalc = 0; - } - - if (options.number && options.minNumber <= 0) { - minNumberCalc = 1; - } else if (!options.number) { - minNumberCalc = 0; - } - - if (options.special && options.minSpecial <= 0) { - minSpecialCalc = 1; - } else if (!options.special) { - minSpecialCalc = 0; - } - - // This should never happen but is a final safety net - if (!options.length || options.length < 1) { - options.length = 10; - } - - const minLength: number = minUppercaseCalc + minLowercaseCalc + minNumberCalc + minSpecialCalc; - // Normalize and Generation both require this modification - if (options.length < minLength) { - options.length = minLength; - } - - // Apply other changes if the options object passed in is for generation - if (forGeneration) { - options.minUppercase = minUppercaseCalc; - options.minLowercase = minLowercaseCalc; - options.minNumber = minNumberCalc; - options.minSpecial = minSpecialCalc; - } - } } diff --git a/libs/common/src/tools/generator/password/password-generator-options-evaluator.spec.ts b/libs/common/src/tools/generator/password/password-generator-options-evaluator.spec.ts new file mode 100644 index 00000000000..e1ca854eb90 --- /dev/null +++ b/libs/common/src/tools/generator/password/password-generator-options-evaluator.spec.ts @@ -0,0 +1,703 @@ +import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options"; + +import { PasswordGenerationOptions } from "./password-generator-options"; +import { + DefaultBoundaries, + PasswordGeneratorOptionsEvaluator, +} from "./password-generator-options-evaluator"; + +describe("Password generator options builder", () => { + const defaultOptions = Object.freeze({ minLength: 0 }); + + describe("constructor()", () => { + it("should set the policy object to a copy of the input policy", () => { + const policy = new PasswordGeneratorPolicyOptions(); + policy.minLength = 10; // arbitrary change for deep equality check + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.policy).toEqual(policy); + expect(builder.policy).not.toBe(policy); + }); + + it("should set default boundaries when a default policy is used", () => { + const policy = new PasswordGeneratorPolicyOptions(); + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.length).toEqual(DefaultBoundaries.length); + expect(builder.minDigits).toEqual(DefaultBoundaries.minDigits); + expect(builder.minSpecialCharacters).toEqual(DefaultBoundaries.minSpecialCharacters); + }); + + it.each([1, 2, 3, 4])( + "should use the default length boundaries when they are greater than `policy.minLength` (= %i)", + (minLength) => { + expect(minLength).toBeLessThan(DefaultBoundaries.length.min); + + const policy = new PasswordGeneratorPolicyOptions(); + policy.minLength = minLength; + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.length).toEqual(DefaultBoundaries.length); + }, + ); + + it.each([8, 20, 100])( + "should use `policy.minLength` (= %i) when it is greater than the default minimum length", + (expectedLength) => { + expect(expectedLength).toBeGreaterThan(DefaultBoundaries.length.min); + expect(expectedLength).toBeLessThanOrEqual(DefaultBoundaries.length.max); + + const policy = new PasswordGeneratorPolicyOptions(); + policy.minLength = expectedLength; + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.length.min).toEqual(expectedLength); + expect(builder.length.max).toEqual(DefaultBoundaries.length.max); + }, + ); + + it.each([150, 300, 9000])( + "should use `policy.minLength` (= %i) when it is greater than the default boundaries", + (expectedLength) => { + expect(expectedLength).toBeGreaterThan(DefaultBoundaries.length.max); + + const policy = new PasswordGeneratorPolicyOptions(); + policy.minLength = expectedLength; + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.length.min).toEqual(expectedLength); + expect(builder.length.max).toEqual(expectedLength); + }, + ); + + it.each([3, 5, 8, 9])( + "should use `policy.numberCount` (= %i) when it is greater than the default minimum digits", + (expectedMinDigits) => { + expect(expectedMinDigits).toBeGreaterThan(DefaultBoundaries.minDigits.min); + expect(expectedMinDigits).toBeLessThanOrEqual(DefaultBoundaries.minDigits.max); + + const policy = new PasswordGeneratorPolicyOptions(); + policy.numberCount = expectedMinDigits; + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.minDigits.min).toEqual(expectedMinDigits); + expect(builder.minDigits.max).toEqual(DefaultBoundaries.minDigits.max); + }, + ); + + it.each([10, 20, 400])( + "should use `policy.numberCount` (= %i) when it is greater than the default digit boundaries", + (expectedMinDigits) => { + expect(expectedMinDigits).toBeGreaterThan(DefaultBoundaries.minDigits.max); + + const policy = new PasswordGeneratorPolicyOptions(); + policy.numberCount = expectedMinDigits; + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.minDigits.min).toEqual(expectedMinDigits); + expect(builder.minDigits.max).toEqual(expectedMinDigits); + }, + ); + + it.each([2, 4, 6])( + "should use `policy.specialCount` (= %i) when it is greater than the default minimum special characters", + (expectedSpecialCharacters) => { + expect(expectedSpecialCharacters).toBeGreaterThan( + DefaultBoundaries.minSpecialCharacters.min, + ); + expect(expectedSpecialCharacters).toBeLessThanOrEqual( + DefaultBoundaries.minSpecialCharacters.max, + ); + + const policy = new PasswordGeneratorPolicyOptions(); + policy.specialCount = expectedSpecialCharacters; + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.minSpecialCharacters.min).toEqual(expectedSpecialCharacters); + expect(builder.minSpecialCharacters.max).toEqual( + DefaultBoundaries.minSpecialCharacters.max, + ); + }, + ); + + it.each([10, 20, 400])( + "should use `policy.specialCount` (= %i) when it is greater than the default special characters boundaries", + (expectedSpecialCharacters) => { + expect(expectedSpecialCharacters).toBeGreaterThan( + DefaultBoundaries.minSpecialCharacters.max, + ); + + const policy = new PasswordGeneratorPolicyOptions(); + policy.specialCount = expectedSpecialCharacters; + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.minSpecialCharacters.min).toEqual(expectedSpecialCharacters); + expect(builder.minSpecialCharacters.max).toEqual(expectedSpecialCharacters); + }, + ); + + it.each([ + [8, 6, 2], + [6, 2, 4], + [16, 8, 8], + ])( + "should ensure the minimum length (= %i) is at least the sum of minimums (= %i + %i)", + (expectedLength, numberCount, specialCount) => { + expect(expectedLength).toBeGreaterThanOrEqual(DefaultBoundaries.length.min); + + const policy = new PasswordGeneratorPolicyOptions(); + policy.numberCount = numberCount; + policy.specialCount = specialCount; + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.length.min).toBeGreaterThanOrEqual(expectedLength); + }, + ); + }); + + describe("applyPolicy(options)", () => { + // All tests should freeze the options to ensure they are not modified + + it.each([ + [false, false], + [true, true], + [false, undefined], + ])( + "should set `options.uppercase` to '%s' when `policy.useUppercase` is false and `options.uppercase` is '%s'", + (expectedUppercase, uppercase) => { + const policy = new PasswordGeneratorPolicyOptions(); + policy.useUppercase = false; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, uppercase }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.uppercase).toEqual(expectedUppercase); + }, + ); + + it.each([false, true, undefined])( + "should set `options.uppercase` (= %s) to true when `policy.useUppercase` is true", + (uppercase) => { + const policy = new PasswordGeneratorPolicyOptions(); + policy.useUppercase = true; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, uppercase }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.uppercase).toEqual(true); + }, + ); + + it.each([ + [false, false], + [true, true], + [false, undefined], + ])( + "should set `options.lowercase` to '%s' when `policy.useLowercase` is false and `options.lowercase` is '%s'", + (expectedLowercase, lowercase) => { + const policy = new PasswordGeneratorPolicyOptions(); + policy.useLowercase = false; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, lowercase }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.lowercase).toEqual(expectedLowercase); + }, + ); + + it.each([false, true, undefined])( + "should set `options.lowercase` (= %s) to true when `policy.useLowercase` is true", + (lowercase) => { + const policy = new PasswordGeneratorPolicyOptions(); + policy.useLowercase = true; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, lowercase }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.lowercase).toEqual(true); + }, + ); + + it.each([ + [false, false], + [true, true], + [false, undefined], + ])( + "should set `options.number` to '%s' when `policy.useNumbers` is false and `options.number` is '%s'", + (expectedNumber, number) => { + const policy = new PasswordGeneratorPolicyOptions(); + policy.useNumbers = false; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, number }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.number).toEqual(expectedNumber); + }, + ); + + it.each([false, true, undefined])( + "should set `options.number` (= %s) to true when `policy.useNumbers` is true", + (number) => { + const policy = new PasswordGeneratorPolicyOptions(); + policy.useNumbers = true; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, number }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.number).toEqual(true); + }, + ); + + it.each([ + [false, false], + [true, true], + [false, undefined], + ])( + "should set `options.special` to '%s' when `policy.useSpecial` is false and `options.special` is '%s'", + (expectedSpecial, special) => { + const policy = new PasswordGeneratorPolicyOptions(); + policy.useSpecial = false; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, special }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.special).toEqual(expectedSpecial); + }, + ); + + it.each([false, true, undefined])( + "should set `options.special` (= %s) to true when `policy.useSpecial` is true", + (special) => { + const policy = new PasswordGeneratorPolicyOptions(); + policy.useSpecial = true; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, special }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.special).toEqual(true); + }, + ); + + it.each([1, 2, 3, 4])( + "should set `options.length` (= %i) to the minimum it is less than the minimum length", + (length) => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + expect(length).toBeLessThan(builder.length.min); + + const options = Object.freeze({ ...defaultOptions, length }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.length).toEqual(builder.length.min); + }, + ); + + it.each([5, 10, 50, 100, 128])( + "should not change `options.length` (= %i) when it is within the boundaries", + (length) => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + expect(length).toBeGreaterThanOrEqual(builder.length.min); + expect(length).toBeLessThanOrEqual(builder.length.max); + + const options = Object.freeze({ ...defaultOptions, length }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.length).toEqual(length); + }, + ); + + it.each([129, 500, 9000])( + "should set `options.length` (= %i) to the maximum length when it is exceeded", + (length) => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + expect(length).toBeGreaterThan(builder.length.max); + + const options = Object.freeze({ ...defaultOptions, length }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.length).toEqual(builder.length.max); + }, + ); + + it.each([ + [true, 1], + [true, 3], + [true, 600], + [false, 0], + [false, -2], + [false, -600], + ])( + "should set `options.number === %s` when `options.minNumber` (= %i) is set to a value greater than 0", + (expectedNumber, minNumber) => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, minNumber }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.number).toEqual(expectedNumber); + }, + ); + + it("should set `options.minNumber` to the minimum value when `options.number` is true", () => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, number: true }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.min); + }); + + it("should set `options.minNumber` to 0 when `options.number` is false", () => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, number: false }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.minNumber).toEqual(0); + }); + + it.each([1, 2, 3, 4])( + "should set `options.minNumber` (= %i) to the minimum it is less than the minimum number", + (minNumber) => { + const policy = new PasswordGeneratorPolicyOptions(); + policy.numberCount = 5; // arbitrary value greater than minNumber + expect(minNumber).toBeLessThan(policy.numberCount); + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, minNumber }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.min); + }, + ); + + it.each([1, 3, 5, 7, 9])( + "should not change `options.minNumber` (= %i) when it is within the boundaries", + (minNumber) => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + expect(minNumber).toBeGreaterThanOrEqual(builder.minDigits.min); + expect(minNumber).toBeLessThanOrEqual(builder.minDigits.max); + + const options = Object.freeze({ ...defaultOptions, minNumber }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.minNumber).toEqual(minNumber); + }, + ); + + it.each([10, 20, 400])( + "should set `options.minNumber` (= %i) to the maximum digit boundary when it is exceeded", + (minNumber) => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + expect(minNumber).toBeGreaterThan(builder.minDigits.max); + + const options = Object.freeze({ ...defaultOptions, minNumber }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.max); + }, + ); + + it.each([ + [true, 1], + [true, 3], + [true, 600], + [false, 0], + [false, -2], + [false, -600], + ])( + "should set `options.special === %s` when `options.minSpecial` (= %i) is set to a value greater than 0", + (expectedSpecial, minSpecial) => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, minSpecial }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.special).toEqual(expectedSpecial); + }, + ); + + it("should set `options.minSpecial` to the minimum value when `options.special` is true", () => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, special: true }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.minSpecial).toEqual(builder.minDigits.min); + }); + + it("should set `options.minSpecial` to 0 when `options.special` is false", () => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, special: false }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.minSpecial).toEqual(0); + }); + + it.each([1, 2, 3, 4])( + "should set `options.minSpecial` (= %i) to the minimum it is less than the minimum special characters", + (minSpecial) => { + const policy = new PasswordGeneratorPolicyOptions(); + policy.specialCount = 5; // arbitrary value greater than minSpecial + expect(minSpecial).toBeLessThan(policy.specialCount); + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, minSpecial }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.minSpecial).toEqual(builder.minSpecialCharacters.min); + }, + ); + + it.each([1, 3, 5, 7, 9])( + "should not change `options.minSpecial` (= %i) when it is within the boundaries", + (minSpecial) => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + expect(minSpecial).toBeGreaterThanOrEqual(builder.minSpecialCharacters.min); + expect(minSpecial).toBeLessThanOrEqual(builder.minSpecialCharacters.max); + + const options = Object.freeze({ ...defaultOptions, minSpecial }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.minSpecial).toEqual(minSpecial); + }, + ); + + it.each([10, 20, 400])( + "should set `options.minSpecial` (= %i) to the maximum special character boundary when it is exceeded", + (minSpecial) => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + expect(minSpecial).toBeGreaterThan(builder.minSpecialCharacters.max); + + const options = Object.freeze({ ...defaultOptions, minSpecial }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.minSpecial).toEqual(builder.minSpecialCharacters.max); + }, + ); + + it("should preserve unknown properties", () => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ + unknown: "property", + another: "unknown property", + }) as PasswordGenerationOptions; + + const sanitizedOptions: any = builder.applyPolicy(options); + + expect(sanitizedOptions.unknown).toEqual("property"); + expect(sanitizedOptions.another).toEqual("unknown property"); + }); + }); + + describe("sanitize(options)", () => { + // All tests should freeze the options to ensure they are not modified + + it.each([ + [1, true], + [0, false], + ])( + "should output `options.minLowercase === %i` when `options.lowercase` is %s", + (expectedMinLowercase, lowercase) => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ lowercase, ...defaultOptions }); + + const actual = builder.sanitize(options); + + expect(actual.minLowercase).toEqual(expectedMinLowercase); + }, + ); + + it.each([ + [1, true], + [0, false], + ])( + "should output `options.minUppercase === %i` when `options.uppercase` is %s", + (expectedMinUppercase, uppercase) => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ uppercase, ...defaultOptions }); + + const actual = builder.sanitize(options); + + expect(actual.minUppercase).toEqual(expectedMinUppercase); + }, + ); + + it.each([ + [1, true], + [0, false], + ])( + "should output `options.minNumber === %i` when `options.number` is %s and `options.minNumber` is not set", + (expectedMinNumber, number) => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ number, ...defaultOptions }); + + const actual = builder.sanitize(options); + + expect(actual.minNumber).toEqual(expectedMinNumber); + }, + ); + + it.each([ + [true, 3], + [true, 2], + [true, 1], + [false, 0], + ])( + "should output `options.number === %s` when `options.minNumber` is %i and `options.number` is not set", + (expectedNumber, minNumber) => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ minNumber, ...defaultOptions }); + + const actual = builder.sanitize(options); + + expect(actual.number).toEqual(expectedNumber); + }, + ); + + it.each([ + [true, 1], + [false, 0], + ])( + "should output `options.minSpecial === %i` when `options.special` is %s and `options.minSpecial` is not set", + (special, expectedMinSpecial) => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ special, ...defaultOptions }); + + const actual = builder.sanitize(options); + + expect(actual.minSpecial).toEqual(expectedMinSpecial); + }, + ); + + it.each([ + [3, true], + [2, true], + [1, true], + [0, false], + ])( + "should output `options.special === %s` when `options.minSpecial` is %i and `options.special` is not set", + (minSpecial, expectedSpecial) => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ minSpecial, ...defaultOptions }); + + const actual = builder.sanitize(options); + + expect(actual.special).toEqual(expectedSpecial); + }, + ); + + it.each([ + [0, 0, 0, 0], + [1, 1, 0, 0], + [0, 0, 1, 1], + [1, 1, 1, 1], + ])( + "should set `options.minLength` to the minimum boundary when the sum of minimums (%i + %i + %i + %i) is less than the default minimum length.", + (minLowercase, minUppercase, minNumber, minSpecial) => { + const sumOfMinimums = minLowercase + minUppercase + minNumber + minSpecial; + expect(sumOfMinimums).toBeLessThan(DefaultBoundaries.length.min); + + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ + minLowercase, + minUppercase, + minNumber, + minSpecial, + ...defaultOptions, + }); + + const actual = builder.sanitize(options); + + expect(actual.minLength).toEqual(builder.length.min); + }, + ); + + it.each([ + [12, 3, 3, 3, 3], + [8, 2, 2, 2, 2], + [9, 3, 3, 3, 0], + ])( + "should set `options.minLength === %i` to the sum of minimums (%i + %i + %i + %i) when the sum is at least the default minimum length.", + (expectedMinLength, minLowercase, minUppercase, minNumber, minSpecial) => { + expect(expectedMinLength).toBeGreaterThanOrEqual(DefaultBoundaries.length.min); + + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ + minLowercase, + minUppercase, + minNumber, + minSpecial, + ...defaultOptions, + }); + + const actual = builder.sanitize(options); + + expect(actual.minLength).toEqual(expectedMinLength); + }, + ); + + it("should preserve unknown properties", () => { + const policy = new PasswordGeneratorPolicyOptions(); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ + unknown: "property", + another: "unknown property", + }) as PasswordGenerationOptions; + + const sanitizedOptions: any = builder.sanitize(options); + + expect(sanitizedOptions.unknown).toEqual("property"); + expect(sanitizedOptions.another).toEqual("unknown property"); + }); + }); +}); diff --git a/libs/common/src/tools/generator/password/password-generator-options-evaluator.ts b/libs/common/src/tools/generator/password/password-generator-options-evaluator.ts new file mode 100644 index 00000000000..0b3aae57ab8 --- /dev/null +++ b/libs/common/src/tools/generator/password/password-generator-options-evaluator.ts @@ -0,0 +1,179 @@ +import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options"; + +import { PasswordGenerationOptions } from "./password-generator-options"; + +function initializeBoundaries() { + const length = Object.freeze({ + min: 5, + max: 128, + }); + + const minDigits = Object.freeze({ + min: 0, + max: 9, + }); + + const minSpecialCharacters = Object.freeze({ + min: 0, + max: 9, + }); + + return Object.freeze({ + length, + minDigits, + minSpecialCharacters, + }); +} + +/** Immutable default boundaries for password generation. + * These are used when the policy does not override a value. + */ +export const DefaultBoundaries = initializeBoundaries(); + +type Boundary = { + readonly min: number; + readonly max: number; +}; + +/** Enforces policy for password generation. + */ +export class PasswordGeneratorOptionsEvaluator { + // This design is not ideal, but it is a step towards a more robust password + // generator. Ideally, `sanitize` would be implemented on an options class, + // and `applyPolicy` would be implemented on a policy class, "mise en place". + // + // The current design of the password generator, unfortunately, would require + // a substantial rewrite to make this feasible. Hopefully this change can be + // applied when the password generator is ported to rust. + + /** Boundaries for the password length. This is always large enough + * to accommodate the minimum number of digits and special characters. + */ + readonly length: Boundary; + + /** Boundaries for the minimum number of digits allowed in the password. + */ + readonly minDigits: Boundary; + + /** Boundaries for the minimum number of special characters allowed + * in the password. + */ + readonly minSpecialCharacters: Boundary; + + /** Policy applied by the evaluator. + */ + readonly policy: PasswordGeneratorPolicyOptions; + + /** Instantiates the evaluator. + * @param policy The policy applied by the evaluator. When this conflicts with + * the defaults, the policy takes precedence. + */ + constructor(policy: PasswordGeneratorPolicyOptions) { + function createBoundary(value: number, defaultBoundary: Boundary): Boundary { + const boundary = { + min: Math.max(defaultBoundary.min, value), + max: Math.max(defaultBoundary.max, value), + }; + + return boundary; + } + + this.policy = policy.clone(); + this.minDigits = createBoundary(policy.numberCount, DefaultBoundaries.minDigits); + this.minSpecialCharacters = createBoundary( + policy.specialCount, + DefaultBoundaries.minSpecialCharacters, + ); + + // the overall length should be at least as long as the sum of the minimums + const minConsistentLength = this.minDigits.min + this.minSpecialCharacters.min; + const minPolicyLength = policy.minLength > 0 ? policy.minLength : DefaultBoundaries.length.min; + const minLength = Math.max(minPolicyLength, minConsistentLength, DefaultBoundaries.length.min); + + this.length = { + min: minLength, + max: Math.max(DefaultBoundaries.length.max, minLength), + }; + } + + /** Apply policy to a set of options. + * @param options The options to build from. These options are not altered. + * @returns A complete password generation request with policy applied. + * @remarks This method only applies policy overrides. + * Pass the result to `sanitize` to ensure consistency. + */ + applyPolicy(options: PasswordGenerationOptions): PasswordGenerationOptions { + function fitToBounds(value: number, boundaries: Boundary) { + const { min, max } = boundaries; + + const withUpperBound = Math.min(value || 0, max); + const withLowerBound = Math.max(withUpperBound, min); + + return withLowerBound; + } + + // apply policy overrides + const uppercase = this.policy.useUppercase || options.uppercase || false; + const lowercase = this.policy.useLowercase || options.lowercase || false; + + // these overrides can cascade numeric fields to boolean fields + const number = this.policy.useNumbers || options.number || options.minNumber > 0; + const special = this.policy.useSpecial || options.special || options.minSpecial > 0; + + // apply boundaries; the boundaries can cascade boolean fields to numeric fields + const length = fitToBounds(options.length, this.length); + const minNumber = fitToBounds(options.minNumber, this.minDigits); + const minSpecial = fitToBounds(options.minSpecial, this.minSpecialCharacters); + + return { + ...options, + length, + uppercase, + lowercase, + number, + minNumber, + special, + minSpecial, + }; + } + + /** Ensures internal options consistency. + * @param options The options to cascade. These options are not altered. + * @returns A new password generation request with cascade applied. + * @remarks This method fills null and undefined values by looking at + * pairs of flags and values (e.g. `number` and `minNumber`). If the flag + * and value are inconsistent, the flag cascades to the value. + */ + sanitize(options: PasswordGenerationOptions): PasswordGenerationOptions { + function cascade(enabled: boolean, value: number): [boolean, number] { + const enabledResult = enabled ?? value > 0; + const valueResult = enabledResult ? value || 1 : 0; + + return [enabledResult, valueResult]; + } + + const [lowercase, minLowercase] = cascade(options.lowercase, options.minLowercase); + const [uppercase, minUppercase] = cascade(options.uppercase, options.minUppercase); + const [number, minNumber] = cascade(options.number, options.minNumber); + const [special, minSpecial] = cascade(options.special, options.minSpecial); + + // minimums can only increase the length + const minConsistentLength = minLowercase + minUppercase + minNumber + minSpecial; + const minLength = Math.max(minConsistentLength, this.length.min); + const length = Math.max(options.length ?? minLength, minLength); + + return { + ...options, + length, + minLength, + lowercase, + minLowercase, + uppercase, + minUppercase, + number, + minNumber, + special, + minSpecial, + }; + } +} diff --git a/libs/common/src/tools/generator/password/password-generator-options.ts b/libs/common/src/tools/generator/password/password-generator-options.ts index 287a91659e4..0f55f8cbc7c 100644 --- a/libs/common/src/tools/generator/password/password-generator-options.ts +++ b/libs/common/src/tools/generator/password/password-generator-options.ts @@ -1,17 +1,105 @@ -export type PasswordGeneratorOptions = { +/** Request format for credential generation. + * This type includes all properties suitable for reactive data binding. + */ +export type PasswordGeneratorOptions = PasswordGenerationOptions & + PassphraseGenerationOptions & { + /** The algorithm to use for credential generation. + * Properties on @see PasswordGenerationOptions should be processed + * only when `type === "password"`. + * Properties on @see PassphraseGenerationOptions should be processed + * only when `type === "passphrase"`. + */ + type?: "password" | "passphrase"; + }; + +/** Request format for password credential generation. + * All members of this type may be `undefined` when the user is + * generating a passphrase. + */ +export type PasswordGenerationOptions = { + /** The length of the password selected by the user */ length?: number; + + /** The minimum length of the password. This defaults to 5, and increases + * to ensure `minLength` is at least as large as the sum of the other minimums. + */ + minLength?: number; + + /** `true` when ambiguous characters may be included in the output. + * `false` when ambiguous characters should not be included in the output. + */ ambiguous?: boolean; + + /** `true` when uppercase ASCII characters should be included in the output + * This value defaults to `false. + */ uppercase?: boolean; + + /** The minimum number of uppercase characters to include in the output. + * The value is ignored when `uppercase` is `false`. + * The value defaults to 1 when `uppercase` is `true`. + */ minUppercase?: number; + + /** `true` when lowercase ASCII characters should be included in the output. + * This value defaults to `false`. + */ lowercase?: boolean; + + /** The minimum number of lowercase characters to include in the output. + * The value defaults to 1 when `lowercase` is `true`. + * The value defaults to 0 when `lowercase` is `false`. + */ minLowercase?: number; + + /** Whether or not to include ASCII digits in the output + * This value defaults to `true` when `minNumber` is at least 1. + * This value defaults to `false` when `minNumber` is less than 1. + */ number?: boolean; + + /** The minimum number of digits to include in the output. + * The value defaults to 1 when `number` is `true`. + * The value defaults to 0 when `number` is `false`. + */ minNumber?: number; + + /** Whether or not to include special characters in the output. + * This value defaults to `true` when `minSpecial` is at least 1. + * This value defaults to `false` when `minSpecial` is less than 1. + */ special?: boolean; + + /** The minimum number of special characters to include in the output. + * This value defaults to 1 when `special` is `true`. + * This value defaults to 0 when `special` is `false`. + */ minSpecial?: number; - numWords?: number; - wordSeparator?: string; - capitalize?: boolean; - includeNumber?: boolean; - type?: "password" | "passphrase"; +}; + +/** Request format for passphrase credential generation. + * The members of this type may be `undefined` when the user is + * generating a password. + */ +export type PassphraseGenerationOptions = { + /** The number of words to include in the passphrase. + * This value defaults to 4. + */ + numWords?: number; + + /** The ASCII separator character to use between words in the passphrase. + * This value defaults to a dash. + * If multiple characters appear in the string, only the first character is used. + */ + wordSeparator?: string; + + /** `true` when the first character of every word should be capitalized. + * This value defaults to `false`. + */ + capitalize?: boolean; + + /** `true` when a number should be included in the passphrase. + * This value defaults to `false`. + */ + includeNumber?: boolean; };