1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-09-27 04:03:00 +02:00

[PM-252] fix inconsistent generator configuration behavior (#6755)

* decompose password generator policy enforcement
* integrate new logic with UI
* improve UX of minimum password length
* improve password generator policy options documentation
* initialize min length to default minimum length boundary
* reset form value on input to prevent UI desync from model

---------

Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
This commit is contained in:
✨ Audrey ✨ 2023-12-12 19:17:20 -05:00 committed by GitHub
parent bfa76885ac
commit df406a9862
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1532 additions and 201 deletions

View File

@ -268,6 +268,9 @@
"length": {
"message": "Length"
},
"passwordMinLength": {
"message": "Minimum password length"
},
"uppercase": {
"message": "Uppercase (A-Z)"
},

View File

@ -341,7 +341,8 @@
}
}
.img-right {
.img-right,
.txt-right {
float: right;
margin-left: 10px;
}

View File

@ -176,7 +176,7 @@
<input
id="length"
type="number"
min="5"
[min]="passwordOptions.minLength"
max="128"
[(ngModel)]="passwordOptions.length"
(change)="savePasswordOptions()"
@ -184,7 +184,7 @@
<input
id="lengthRange"
type="range"
min="5"
[min]="passwordOptions.minLength"
max="128"
step="1"
[(ngModel)]="passwordOptions.length"
@ -194,6 +194,18 @@
tabindex="-1"
/>
</div>
<div class="box-content-row" appBoxRow>
<span>{{ "passwordMinLength" | i18n }}</span>
<span
class="sr-only"
attr.aria-label="{{ 'passwordMinLength' | i18n }}"
role="status"
aria-live="polite"
>
{{ passwordOptionsMinLengthForReader$ | async }}
</span>
<span class="txt-right">{{ passwordOptions.minLength }}</span>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="uppercase">A-Z</label>
<input
@ -221,10 +233,10 @@
<input
id="numbers"
type="checkbox"
(change)="savePasswordOptions()"
attr.aria-label="{{ 'numbers' | i18n }}"
[disabled]="enforcedPasswordPolicyOptions.useNumbers"
[(ngModel)]="passwordOptions.number"
[ngModel]="passwordOptions.number"
(ngModelChange)="setPasswordOptionsNumber($event)"
/>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>
@ -232,10 +244,10 @@
<input
id="special"
type="checkbox"
(change)="savePasswordOptions()"
attr.aria-label="{{ 'specialCharacters' | i18n }}"
[disabled]="enforcedPasswordPolicyOptions.useSpecial"
[(ngModel)]="passwordOptions.special"
[ngModel]="passwordOptions.special"
(ngModelChange)="setPasswordOptionsSpecial($event)"
/>
</div>
</div>
@ -249,8 +261,8 @@
type="number"
min="0"
max="9"
(change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minNumber"
(input)="onPasswordOptionsMinNumberInput($event)"
/>
</div>
<div class="box-content-row box-content-row-input" appBoxRow>
@ -260,8 +272,8 @@
type="number"
min="0"
max="9"
(change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minSpecial"
(input)="onPasswordOptionsMinSpecialInput($event)"
/>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>

View File

@ -200,7 +200,7 @@
<input
id="length"
type="number"
min="5"
[min]="passwordOptions.minLength"
max="128"
[(ngModel)]="passwordOptions.length"
(blur)="savePasswordOptions()"
@ -208,7 +208,7 @@
<input
id="lengthRange"
type="range"
min="5"
[min]="passwordOptions.minLength"
max="128"
step="1"
[(ngModel)]="passwordOptions.length"
@ -218,6 +218,18 @@
tabindex="-1"
/>
</div>
<div class="box-content-row" appBoxRow>
<span>{{ "passwordMinLength" | i18n }}</span>
<span class="txt-right">{{ passwordOptions.minLength }}</span>
<span
class="sr-only"
attr.aria-label="{{ 'passwordMinLength' | i18n }}"
role="status"
aria-live="polite"
>
{{ passwordOptionsMinLengthForReader$ | async }}
</span>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="uppercase">A-Z</label>
<input
@ -247,7 +259,8 @@
type="checkbox"
(change)="savePasswordOptions()"
[disabled]="enforcedPasswordPolicyOptions?.useNumbers"
[(ngModel)]="passwordOptions.number"
[ngModel]="passwordOptions.number"
(ngModelChange)="setPasswordOptionsNumber($event)"
attr.aria-label="{{ 'numbers' | i18n }}"
/>
</div>
@ -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 }}"
/>
</div>
@ -275,6 +289,7 @@
max="9"
(change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minNumber"
(input)="onPasswordOptionsMinNumberInput($event)"
/>
</div>
<div class="box-content-row box-content-row-input" appBoxRow>
@ -286,6 +301,7 @@
max="9"
(change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minSpecial"
(input)="onPasswordOptionsMinSpecialInput($event)"
/>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>

View File

@ -403,6 +403,9 @@
"length": {
"message": "Length"
},
"passwordMinLength": {
"message": "Minimum password length"
},
"uppercase": {
"message": "Uppercase (A-Z)"
},

View File

@ -217,7 +217,8 @@
}
}
.img-right {
.img-right,
.txt-right {
float: right;
margin-left: 10px;
}

View File

@ -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()"
/>
</div>
<div class="form-group col-4">
<label for="min-length">{{ "passwordMinLength" | i18n }}</label>
<input
id="min-length"
class="form-control"
type="text"
readonly="true"
[value]="passwordOptions.length"
/>
<span
class="sr-only"
attr.aria-label="{{ 'passwordMinLength' | i18n }}"
role="status"
aria-live="polite"
>
{{ passwordOptionsMinLengthForReader$ | async }}
</span>
</div>
<div class="form-group col-4">
<label for="min-number">{{ "minNumbers" | i18n }}</label>
<input
@ -124,8 +142,8 @@
type="number"
min="0"
max="9"
(blur)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minNumber"
(input)="onPasswordOptionsMinNumberInput($event)"
(change)="minNumberChanged()"
/>
</div>
@ -137,8 +155,8 @@
type="number"
min="0"
max="9"
(blur)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minSpecial"
(input)="onPasswordOptionsMinSpecialInput($event)"
(change)="minSpecialChanged()"
/>
</div>
@ -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 }}"
/>

View File

@ -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."

View File

@ -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<number>(
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() {

View File

@ -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;
}
}

View File

@ -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");
});
});
});

View File

@ -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,
};
}
}

View File

@ -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<string> {
// 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<string> {
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<PasswordGeneratorPolicyOptions> {
@ -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;
}
}
}

View File

@ -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");
});
});
});

View File

@ -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,
};
}
}

View File

@ -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;
};