From dbf836b573f27be041bb286b2293fcad54b84554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Tue, 23 Jan 2024 14:22:52 -0500 Subject: [PATCH] [PM-5606] Add reactive generator service (#7446) --- libs/common/spec/index.ts | 2 + libs/common/src/enums/feature-flag.enum.ts | 1 + .../src/platform/state/state-definitions.ts | 5 +- .../generator-strategy.abstraction.ts | 32 +++ .../generator.service.abstraction.ts | 37 ++++ .../src/tools/generator/abstractions/index.ts | 3 + .../policy-evaluator.abstraction.ts | 28 +++ .../default-generator.service.spec.ts | 192 ++++++++++++++++++ .../generator/default-generator.service.ts | 64 ++++++ libs/common/src/tools/generator/index.ts | 4 + .../tools/generator/key-definition.spec.ts | 49 +++++ .../src/tools/generator/key-definitions.ts | 49 +++++ .../src/tools/generator/password/index.ts | 7 + .../password/password-generation-options.ts | 83 ++++++++ ...ssword-generator-options-evaluator.spec.ts | 165 ++++++++++----- .../password-generator-options-evaluator.ts | 45 ++-- .../password/password-generator-options.ts | 67 +----- .../password/password-generator-policy.ts | 50 +++++ .../password-generator-strategy.spec.ts | 108 ++++++++++ .../password/password-generator-strategy.ts | 60 ++++++ 20 files changed, 917 insertions(+), 134 deletions(-) create mode 100644 libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts create mode 100644 libs/common/src/tools/generator/abstractions/generator.service.abstraction.ts create mode 100644 libs/common/src/tools/generator/abstractions/index.ts create mode 100644 libs/common/src/tools/generator/abstractions/policy-evaluator.abstraction.ts create mode 100644 libs/common/src/tools/generator/default-generator.service.spec.ts create mode 100644 libs/common/src/tools/generator/default-generator.service.ts create mode 100644 libs/common/src/tools/generator/index.ts create mode 100644 libs/common/src/tools/generator/key-definition.spec.ts create mode 100644 libs/common/src/tools/generator/key-definitions.ts create mode 100644 libs/common/src/tools/generator/password/password-generation-options.ts create mode 100644 libs/common/src/tools/generator/password/password-generator-policy.ts create mode 100644 libs/common/src/tools/generator/password/password-generator-strategy.spec.ts create mode 100644 libs/common/src/tools/generator/password/password-generator-strategy.ts diff --git a/libs/common/spec/index.ts b/libs/common/spec/index.ts index 494b31b521..4ba9f3d393 100644 --- a/libs/common/spec/index.ts +++ b/libs/common/spec/index.ts @@ -1,3 +1,5 @@ export * from "./utils"; export * from "./intercept-console"; export * from "./matchers"; +export * from "./fake-state-provider"; +export * from "./fake-account-service"; diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index cdcddd6fb0..de78c8810a 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -4,6 +4,7 @@ export enum FeatureFlag { ItemShare = "item-share", FlexibleCollectionsV1 = "flexible-collections-v-1", // v-1 is intentional BulkCollectionAccess = "bulk-collection-access", + GeneratorToolsModernization = "generator-tools-modernization", KeyRotationImprovements = "key-rotation-improvements", FlexibleCollectionsMigration = "flexible-collections-migration", } diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index eae8786a6a..898ac5c2f9 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -19,6 +19,9 @@ import { StateDefinition } from "./state-definition"; export const ACCOUNT_MEMORY = new StateDefinition("account", "memory"); +export const BILLING_BANNERS_DISK = new StateDefinition("billingBanners", "disk"); + export const CRYPTO_DISK = new StateDefinition("crypto", "disk"); -export const BILLING_BANNERS_DISK = new StateDefinition("billingBanners", "disk"); +export const GENERATOR_DISK = new StateDefinition("generator", "disk"); +export const GENERATOR_MEMORY = new StateDefinition("generator", "memory"); diff --git a/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts b/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts new file mode 100644 index 0000000000..2ec098bc41 --- /dev/null +++ b/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts @@ -0,0 +1,32 @@ +import { PolicyType } from "../../../admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy as AdminPolicy } from "../../../admin-console/models/domain/policy"; +import { KeyDefinition } from "../../../platform/state"; + +import { PolicyEvaluator } from "./policy-evaluator.abstraction"; + +/** Tailors the generator service to generate a specific kind of credentials */ +export abstract class GeneratorStrategy { + /** The key used when storing credentials on disk. */ + disk: KeyDefinition; + + /** Identifies the policy enforced by the generator. */ + policy: PolicyType; + + /** Length of time in milliseconds to cache the evaluator */ + cache_ms: number; + + /** Creates an evaluator from a generator policy. + * @param policy The policy being evaluated. + * @returns the policy evaluator. + * @throws when the policy's type does not match the generator's policy type. + */ + evaluator: (policy: AdminPolicy) => PolicyEvaluator; + + /** Generates credentials from the given options. + * @param options The options used to generate the credentials. + * @returns a promise that resolves to the generated credentials. + */ + generate: (options: Options) => Promise; +} diff --git a/libs/common/src/tools/generator/abstractions/generator.service.abstraction.ts b/libs/common/src/tools/generator/abstractions/generator.service.abstraction.ts new file mode 100644 index 0000000000..e64e078779 --- /dev/null +++ b/libs/common/src/tools/generator/abstractions/generator.service.abstraction.ts @@ -0,0 +1,37 @@ +import { Observable } from "rxjs"; + +import { PolicyEvaluator } from "./policy-evaluator.abstraction"; + +/** Generates credentials used for user authentication + * @typeParam Options the credential generation configuration + * @typeParam Policy the policy enforced by the generator + */ +export abstract class GeneratorService { + /** An observable monitoring the options saved to disk. + * The observable updates when the options are saved. + */ + options$: Observable; + + /** An observable monitoring the options used to enforce policy. + * The observable updates when the policy changes. + */ + policy$: Observable>; + + /** Enforces the policy on the given options + * @param options the options to enforce the policy on + * @returns a new instance of the options with the policy enforced + */ + enforcePolicy: (options: Options) => Promise; + + /** Generates credentials + * @param options the options to generate credentials with + * @returns a promise that resolves with the generated credentials + */ + generate: (options: Options) => Promise; + + /** Saves the given options to disk. + * @param options the options to save + * @returns a promise that resolves when the options are saved + */ + saveOptions: (options: Options) => Promise; +} diff --git a/libs/common/src/tools/generator/abstractions/index.ts b/libs/common/src/tools/generator/abstractions/index.ts new file mode 100644 index 0000000000..03285dd5ff --- /dev/null +++ b/libs/common/src/tools/generator/abstractions/index.ts @@ -0,0 +1,3 @@ +export { GeneratorService } from "./generator.service.abstraction"; +export { GeneratorStrategy } from "./generator-strategy.abstraction"; +export { PolicyEvaluator } from "./policy-evaluator.abstraction"; diff --git a/libs/common/src/tools/generator/abstractions/policy-evaluator.abstraction.ts b/libs/common/src/tools/generator/abstractions/policy-evaluator.abstraction.ts new file mode 100644 index 0000000000..f4e9186c9c --- /dev/null +++ b/libs/common/src/tools/generator/abstractions/policy-evaluator.abstraction.ts @@ -0,0 +1,28 @@ +/** Applies policy to a generation request */ +export abstract class PolicyEvaluator { + /** The policy to enforce */ + policy: Policy; + + /** Returns true when a policy is being enforced by the evaluator. + * @remarks `applyPolicy` should be called when a policy is not in + * effect to enforce the application's default policy. + */ + policyInEffect: boolean; + + /** Apply policy to a set of options. + * @param options The options to build from. These options are not altered. + * @returns A complete generation request with policy applied. + * @remarks This method only applies policy overrides. + * Pass the result to `sanitize` to ensure consistency. + */ + applyPolicy: (options: PolicyTarget) => PolicyTarget; + + /** Ensures internal options consistency. + * @param options The options to cascade. These options are not altered. + * @returns A new 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: PolicyTarget) => PolicyTarget; +} diff --git a/libs/common/src/tools/generator/default-generator.service.spec.ts b/libs/common/src/tools/generator/default-generator.service.spec.ts new file mode 100644 index 0000000000..4293b0168c --- /dev/null +++ b/libs/common/src/tools/generator/default-generator.service.spec.ts @@ -0,0 +1,192 @@ +/** + * include structuredClone in test environment. + * @jest-environment ../../../../shared/test.environment.ts + */ + +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, firstValueFrom } from "rxjs"; + +import { FakeActiveUserStateProvider, mockAccountServiceWith } from "../../../spec"; +import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "../../admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "../../admin-console/models/domain/policy"; +import { Utils } from "../../platform/misc/utils"; +import { ActiveUserState, ActiveUserStateProvider, KeyDefinition } from "../../platform/state"; +import { UserId } from "../../types/guid"; + +import { GeneratorStrategy, PolicyEvaluator } from "./abstractions"; +import { PASSPHRASE_SETTINGS, PASSWORD_SETTINGS } from "./key-definitions"; +import { PasswordGenerationOptions } from "./password"; + +import { DefaultGeneratorService } from "."; + +function mockPolicyService(config?: { data?: any; policy?: BehaviorSubject }) { + const state = mock({ data: config?.data ?? {} }); + const subject = config?.policy ?? new BehaviorSubject(state); + + const service = mock(); + service.get$.mockReturnValue(subject.asObservable()); + + return service; +} + +function mockGeneratorStrategy(config?: { + disk?: KeyDefinition; + policy?: PolicyType; + evaluator?: any; +}) { + const strategy = mock>({ + // intentionally arbitrary so that tests that need to check + // whether they're used properly are guaranteed to test + // the value from `config`. + disk: config?.disk ?? {}, + policy: config?.policy ?? PolicyType.DisableSend, + evaluator: jest.fn(() => config?.evaluator ?? mock>()), + }); + + return strategy; +} + +// FIXME: Use the fake instead, once it's updated to monitor its method calls. +function mockStateProvider(): [ + ActiveUserStateProvider, + ActiveUserState, +] { + const state = mock>(); + const provider = mock(); + provider.get.mockReturnValue(state); + + return [provider, state]; +} + +function fakeStateProvider(key: KeyDefinition, initalValue: any): FakeActiveUserStateProvider { + const userId = Utils.newGuid() as UserId; + const acctService = mockAccountServiceWith(userId); + const provider = new FakeActiveUserStateProvider(acctService); + provider.mockFor(key.key, initalValue); + return provider; +} + +describe("Password generator service", () => { + describe("constructor()", () => { + it("should initialize the password generator policy", () => { + const policy = mockPolicyService(); + const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator }); + + new DefaultGeneratorService(strategy, policy, null); + + expect(policy.get$).toHaveBeenCalledWith(PolicyType.PasswordGenerator); + }); + }); + + describe("options$", () => { + it("should return the state from strategy.key", () => { + const policy = mockPolicyService(); + const strategy = mockGeneratorStrategy({ disk: PASSPHRASE_SETTINGS }); + const [state] = mockStateProvider(); + const service = new DefaultGeneratorService(strategy, policy, state); + + // invoke the getter. It returns the state but that's not important. + service.options$; + + expect(state.get).toHaveBeenCalledWith(PASSPHRASE_SETTINGS); + }); + }); + + describe("saveOptions()", () => { + it("should update the state at strategy.key", async () => { + const policy = mockPolicyService(); + const [provider, state] = mockStateProvider(); + const strategy = mockGeneratorStrategy({ disk: PASSWORD_SETTINGS }); + const service = new DefaultGeneratorService(strategy, policy, provider); + + await service.saveOptions({}); + + expect(provider.get).toHaveBeenCalledWith(PASSWORD_SETTINGS); + expect(state.update).toHaveBeenCalled(); + }); + + it("should trigger an options$ update", async () => { + const policy = mockPolicyService(); + const strategy = mockGeneratorStrategy(); + // using the fake here because we're testing that the update and the + // property are wired together. If we were to mock that, we'd be testing + // the mock configuration instead of the wiring. + const provider = fakeStateProvider(strategy.disk, { length: 9 }); + const service = new DefaultGeneratorService(strategy, policy, provider); + + await service.saveOptions({ length: 10 }); + + const options = await firstValueFrom(service.options$); + expect(options).toEqual({ length: 10 }); + }); + }); + + describe("policy$", () => { + it("should map the policy using the generation strategy", async () => { + const policyService = mockPolicyService(); + const evaluator = mock>(); + const strategy = mockGeneratorStrategy({ evaluator }); + + const service = new DefaultGeneratorService(strategy, policyService, null); + + const policy = await firstValueFrom(service.policy$); + + expect(policy).toBe(evaluator); + }); + }); + + describe("enforcePolicy()", () => { + describe("should load the policy", () => { + it("from the cache by default", async () => { + const policy = mockPolicyService(); + const strategy = mockGeneratorStrategy(); + const service = new DefaultGeneratorService(strategy, policy, null); + + await service.enforcePolicy({}); + await service.enforcePolicy({}); + + expect(strategy.evaluator).toHaveBeenCalledTimes(1); + }); + + it("from the policy service when the policy changes", async () => { + const policy = new BehaviorSubject(mock({ data: {} })); + const policyService = mockPolicyService({ policy }); + const strategy = mockGeneratorStrategy(); + const service = new DefaultGeneratorService(strategy, policyService, null); + + await service.enforcePolicy({}); + policy.next(mock({ data: { some: "change" } })); + await service.enforcePolicy({}); + + expect(strategy.evaluator).toHaveBeenCalledTimes(2); + }); + }); + + it("should evaluate the policy using the generation strategy", async () => { + const policy = mockPolicyService(); + const evaluator = mock>(); + const strategy = mockGeneratorStrategy({ evaluator }); + const service = new DefaultGeneratorService(strategy, policy, null); + + await service.enforcePolicy({}); + + expect(evaluator.applyPolicy).toHaveBeenCalled(); + expect(evaluator.sanitize).toHaveBeenCalled(); + }); + }); + + describe("generate()", () => { + it("should invoke the generation strategy", async () => { + const strategy = mockGeneratorStrategy(); + const policy = mockPolicyService(); + const service = new DefaultGeneratorService(strategy, policy, null); + + await service.generate({}); + + expect(strategy.generate).toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/common/src/tools/generator/default-generator.service.ts b/libs/common/src/tools/generator/default-generator.service.ts new file mode 100644 index 0000000000..55c15a23c2 --- /dev/null +++ b/libs/common/src/tools/generator/default-generator.service.ts @@ -0,0 +1,64 @@ +import { firstValueFrom, map, share, timer, ReplaySubject, Observable } from "rxjs"; + +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; +import { ActiveUserStateProvider } from "../../platform/state"; + +import { GeneratorStrategy, GeneratorService, PolicyEvaluator } from "./abstractions"; + +/** {@link GeneratorServiceAbstraction} */ +export class DefaultGeneratorService implements GeneratorService { + /** Instantiates the generator service + * @param strategy tailors the service to a specific generator type + * (e.g. password, passphrase) + * @param policy provides the policy to enforce + * @param state saves and loads password generation options to the location + * specified by the strategy + */ + constructor( + private strategy: GeneratorStrategy, + private policy: PolicyService, + private state: ActiveUserStateProvider, + ) { + this._policy$ = this.policy.get$(this.strategy.policy).pipe( + map((policy) => this.strategy.evaluator(policy)), + share({ + // cache evaluator in a replay subject to amortize creation cost + // and reduce GC pressure. + connector: () => new ReplaySubject(1), + resetOnRefCountZero: () => timer(this.strategy.cache_ms), + }), + ); + } + + private _policy$: Observable>; + + /** {@link GeneratorService.options$} */ + get options$() { + return this.state.get(this.strategy.disk).state$; + } + + /** {@link GeneratorService.saveOptions} */ + async saveOptions(options: Options): Promise { + await this.state.get(this.strategy.disk).update(() => options); + } + + /** {@link GeneratorService.policy$} */ + get policy$() { + return this._policy$; + } + + /** {@link GeneratorService.enforcePolicy} */ + async enforcePolicy(options: Options): Promise { + const policy = await firstValueFrom(this._policy$); + const evaluated = policy.applyPolicy(options); + const sanitized = policy.sanitize(evaluated); + return sanitized; + } + + /** {@link GeneratorService.generate} */ + async generate(options: Options): Promise { + return await this.strategy.generate(options); + } +} diff --git a/libs/common/src/tools/generator/index.ts b/libs/common/src/tools/generator/index.ts new file mode 100644 index 0000000000..ae35c9ce0a --- /dev/null +++ b/libs/common/src/tools/generator/index.ts @@ -0,0 +1,4 @@ +export * from "./abstractions/index"; +export * from "./password/index"; + +export { DefaultGeneratorService } from "./default-generator.service"; diff --git a/libs/common/src/tools/generator/key-definition.spec.ts b/libs/common/src/tools/generator/key-definition.spec.ts new file mode 100644 index 0000000000..10ff62d22b --- /dev/null +++ b/libs/common/src/tools/generator/key-definition.spec.ts @@ -0,0 +1,49 @@ +import { + ENCRYPTED_HISTORY, + ENCRYPTED_USERNAME_SETTINGS, + PASSPHRASE_SETTINGS, + PASSWORD_SETTINGS, + PLAINTEXT_USERNAME_SETTINGS, +} from "./key-definitions"; + +describe("Key definitions", () => { + describe("PASSWORD_SETTINGS", () => { + it("should pass through deserialization", () => { + const value = {}; + const result = PASSWORD_SETTINGS.deserializer(value); + expect(result).toBe(value); + }); + }); + + describe("PASSPHRASE_SETTINGS", () => { + it("should pass through deserialization", () => { + const value = {}; + const result = PASSPHRASE_SETTINGS.deserializer(value); + expect(result).toBe(value); + }); + }); + + describe("ENCRYPTED_USERNAME_SETTINGS", () => { + it("should pass through deserialization", () => { + const value = {}; + const result = ENCRYPTED_USERNAME_SETTINGS.deserializer(value); + expect(result).toBe(value); + }); + }); + + describe("PLAINTEXT_USERNAME_SETTINGS", () => { + it("should pass through deserialization", () => { + const value = {}; + const result = PLAINTEXT_USERNAME_SETTINGS.deserializer(value); + expect(result).toBe(value); + }); + }); + + describe("ENCRYPTED_HISTORY", () => { + it("should pass through deserialization", () => { + const value = {}; + const result = ENCRYPTED_HISTORY.deserializer(value as any); + expect(result).toBe(value); + }); + }); +}); diff --git a/libs/common/src/tools/generator/key-definitions.ts b/libs/common/src/tools/generator/key-definitions.ts new file mode 100644 index 0000000000..8e9ed24fcb --- /dev/null +++ b/libs/common/src/tools/generator/key-definitions.ts @@ -0,0 +1,49 @@ +import { GENERATOR_DISK, GENERATOR_MEMORY, KeyDefinition } from "../../platform/state"; + +import { GeneratedPasswordHistory } from "./password/generated-password-history"; +import { PasswordGenerationOptions } from "./password/password-generation-options"; + +/** plaintext password generation options */ +export const PASSWORD_SETTINGS = new KeyDefinition( + GENERATOR_DISK, + "passwordGeneratorSettings", + { + deserializer: (value) => value, + }, +); + +/** plaintext passphrase generation options */ +export const PASSPHRASE_SETTINGS = new KeyDefinition( + GENERATOR_DISK, + "passphraseGeneratorSettings", + { + deserializer: (value) => value, + }, +); + +/** plaintext username generation options */ +export const ENCRYPTED_USERNAME_SETTINGS = new KeyDefinition( + GENERATOR_DISK, + "usernameGeneratorSettings", + { + deserializer: (value) => value, + }, +); + +/** plaintext username generation options */ +export const PLAINTEXT_USERNAME_SETTINGS = new KeyDefinition( + GENERATOR_MEMORY, + "usernameGeneratorSettings", + { + deserializer: (value) => value, + }, +); + +/** encrypted password generation history */ +export const ENCRYPTED_HISTORY = new KeyDefinition( + GENERATOR_DISK, + "passwordGeneratorHistory", + { + deserializer: (value) => value, + }, +); diff --git a/libs/common/src/tools/generator/password/index.ts b/libs/common/src/tools/generator/password/index.ts index bacc2c0c70..0fcbbf5616 100644 --- a/libs/common/src/tools/generator/password/index.ts +++ b/libs/common/src/tools/generator/password/index.ts @@ -1,3 +1,10 @@ +// password generator "v2" interfaces +export * from "./password-generation-options"; +export { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator"; +export { PasswordGeneratorPolicy } from "./password-generator-policy"; +export { PasswordGeneratorStrategy } from "./password-generator-strategy"; + +// legacy interfaces export { PasswordGeneratorOptions } from "./password-generator-options"; export { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction"; export { PasswordGenerationService } from "./password-generation.service"; diff --git a/libs/common/src/tools/generator/password/password-generation-options.ts b/libs/common/src/tools/generator/password/password-generation-options.ts new file mode 100644 index 0000000000..55b27e4e7a --- /dev/null +++ b/libs/common/src/tools/generator/password/password-generation-options.ts @@ -0,0 +1,83 @@ +import { DefaultBoundaries } from "./password-generator-options-evaluator"; + +/** Request format for password credential generation. + * All members of this type may be `undefined` when the user is + * generating a passphrase. + * + * @remarks The name of this type is a bit of a misnomer. This type + * it is used with the "password generator" types. The name + * `PasswordGeneratorOptions` is already in use by legacy code. + */ +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; +}; + +/** The default options for password generation. */ +export const DefaultPasswordGenerationOptions: Partial = Object.freeze({ + length: 14, + minLength: DefaultBoundaries.length.min, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + minNumber: 1, + special: true, + minSpecial: 1, +}); 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 index e1ca854eb9..1b3f228920 100644 --- 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 @@ -1,17 +1,19 @@ -import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options"; +/** + * include structuredClone in test environment. + * @jest-environment ../../../../shared/test.environment.ts + */ -import { PasswordGenerationOptions } from "./password-generator-options"; -import { - DefaultBoundaries, - PasswordGeneratorOptionsEvaluator, -} from "./password-generator-options-evaluator"; +import { DefaultBoundaries } from "./password-generator-options-evaluator"; +import { DisabledPasswordGeneratorPolicy } from "./password-generator-policy"; + +import { PasswordGenerationOptions, PasswordGeneratorOptionsEvaluator } from "."; 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(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); policy.minLength = 10; // arbitrary change for deep equality check const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -21,7 +23,7 @@ describe("Password generator options builder", () => { }); it("should set default boundaries when a default policy is used", () => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -35,7 +37,7 @@ describe("Password generator options builder", () => { (minLength) => { expect(minLength).toBeLessThan(DefaultBoundaries.length.min); - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); policy.minLength = minLength; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -50,7 +52,7 @@ describe("Password generator options builder", () => { expect(expectedLength).toBeGreaterThan(DefaultBoundaries.length.min); expect(expectedLength).toBeLessThanOrEqual(DefaultBoundaries.length.max); - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); policy.minLength = expectedLength; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -65,7 +67,7 @@ describe("Password generator options builder", () => { (expectedLength) => { expect(expectedLength).toBeGreaterThan(DefaultBoundaries.length.max); - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); policy.minLength = expectedLength; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -81,7 +83,7 @@ describe("Password generator options builder", () => { expect(expectedMinDigits).toBeGreaterThan(DefaultBoundaries.minDigits.min); expect(expectedMinDigits).toBeLessThanOrEqual(DefaultBoundaries.minDigits.max); - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); policy.numberCount = expectedMinDigits; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -96,7 +98,7 @@ describe("Password generator options builder", () => { (expectedMinDigits) => { expect(expectedMinDigits).toBeGreaterThan(DefaultBoundaries.minDigits.max); - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); policy.numberCount = expectedMinDigits; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -116,7 +118,7 @@ describe("Password generator options builder", () => { DefaultBoundaries.minSpecialCharacters.max, ); - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); policy.specialCount = expectedSpecialCharacters; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -135,7 +137,7 @@ describe("Password generator options builder", () => { DefaultBoundaries.minSpecialCharacters.max, ); - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); policy.specialCount = expectedSpecialCharacters; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -154,7 +156,7 @@ describe("Password generator options builder", () => { (expectedLength, numberCount, specialCount) => { expect(expectedLength).toBeGreaterThanOrEqual(DefaultBoundaries.length.min); - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); policy.numberCount = numberCount; policy.specialCount = specialCount; @@ -165,6 +167,71 @@ describe("Password generator options builder", () => { ); }); + describe("policyInEffect", () => { + it("should return false when the policy has no effect", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(false); + }); + + it("should return true when the policy has a minlength greater than the default boundary", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.minLength = DefaultBoundaries.length.min + 1; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(true); + }); + + it("should return true when the policy has a number count greater than the default boundary", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.numberCount = DefaultBoundaries.minDigits.min + 1; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(true); + }); + + it("should return true when the policy has a special character count greater than the default boundary", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.specialCount = DefaultBoundaries.minSpecialCharacters.min + 1; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(true); + }); + + it("should return true when the policy has uppercase enabled", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.useUppercase = true; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(true); + }); + + it("should return true when the policy has lowercase enabled", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.useLowercase = true; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(true); + }); + + it("should return true when the policy has numbers enabled", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.useNumbers = true; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(true); + }); + + it("should return true when the policy has special characters enabled", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.useSpecial = true; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(true); + }); + }); + describe("applyPolicy(options)", () => { // All tests should freeze the options to ensure they are not modified @@ -175,7 +242,7 @@ describe("Password generator options builder", () => { ])( "should set `options.uppercase` to '%s' when `policy.useUppercase` is false and `options.uppercase` is '%s'", (expectedUppercase, uppercase) => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); policy.useUppercase = false; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, uppercase }); @@ -189,7 +256,7 @@ describe("Password generator options builder", () => { it.each([false, true, undefined])( "should set `options.uppercase` (= %s) to true when `policy.useUppercase` is true", (uppercase) => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); policy.useUppercase = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, uppercase }); @@ -207,7 +274,7 @@ describe("Password generator options builder", () => { ])( "should set `options.lowercase` to '%s' when `policy.useLowercase` is false and `options.lowercase` is '%s'", (expectedLowercase, lowercase) => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); policy.useLowercase = false; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, lowercase }); @@ -221,7 +288,7 @@ describe("Password generator options builder", () => { it.each([false, true, undefined])( "should set `options.lowercase` (= %s) to true when `policy.useLowercase` is true", (lowercase) => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); policy.useLowercase = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, lowercase }); @@ -239,7 +306,7 @@ describe("Password generator options builder", () => { ])( "should set `options.number` to '%s' when `policy.useNumbers` is false and `options.number` is '%s'", (expectedNumber, number) => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); policy.useNumbers = false; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, number }); @@ -253,7 +320,7 @@ describe("Password generator options builder", () => { it.each([false, true, undefined])( "should set `options.number` (= %s) to true when `policy.useNumbers` is true", (number) => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); policy.useNumbers = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, number }); @@ -271,7 +338,7 @@ describe("Password generator options builder", () => { ])( "should set `options.special` to '%s' when `policy.useSpecial` is false and `options.special` is '%s'", (expectedSpecial, special) => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); policy.useSpecial = false; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, special }); @@ -285,7 +352,7 @@ describe("Password generator options builder", () => { it.each([false, true, undefined])( "should set `options.special` (= %s) to true when `policy.useSpecial` is true", (special) => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); policy.useSpecial = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, special }); @@ -299,7 +366,7 @@ describe("Password generator options builder", () => { 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 policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(length).toBeLessThan(builder.length.min); @@ -314,7 +381,7 @@ describe("Password generator options builder", () => { 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 policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(length).toBeGreaterThanOrEqual(builder.length.min); expect(length).toBeLessThanOrEqual(builder.length.max); @@ -330,7 +397,7 @@ describe("Password generator options builder", () => { it.each([129, 500, 9000])( "should set `options.length` (= %i) to the maximum length when it is exceeded", (length) => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(length).toBeGreaterThan(builder.length.max); @@ -352,7 +419,7 @@ describe("Password generator options builder", () => { ])( "should set `options.number === %s` when `options.minNumber` (= %i) is set to a value greater than 0", (expectedNumber, minNumber) => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, minNumber }); @@ -363,7 +430,7 @@ describe("Password generator options builder", () => { ); it("should set `options.minNumber` to the minimum value when `options.number` is true", () => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, number: true }); @@ -373,7 +440,7 @@ describe("Password generator options builder", () => { }); it("should set `options.minNumber` to 0 when `options.number` is false", () => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, number: false }); @@ -385,7 +452,7 @@ describe("Password generator options builder", () => { 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(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); policy.numberCount = 5; // arbitrary value greater than minNumber expect(minNumber).toBeLessThan(policy.numberCount); @@ -401,7 +468,7 @@ describe("Password generator options builder", () => { 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 policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(minNumber).toBeGreaterThanOrEqual(builder.minDigits.min); expect(minNumber).toBeLessThanOrEqual(builder.minDigits.max); @@ -417,7 +484,7 @@ describe("Password generator options builder", () => { 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 policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(minNumber).toBeGreaterThan(builder.minDigits.max); @@ -439,7 +506,7 @@ describe("Password generator options builder", () => { ])( "should set `options.special === %s` when `options.minSpecial` (= %i) is set to a value greater than 0", (expectedSpecial, minSpecial) => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, minSpecial }); @@ -450,7 +517,7 @@ describe("Password generator options builder", () => { ); it("should set `options.minSpecial` to the minimum value when `options.special` is true", () => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, special: true }); @@ -460,7 +527,7 @@ describe("Password generator options builder", () => { }); it("should set `options.minSpecial` to 0 when `options.special` is false", () => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, special: false }); @@ -472,7 +539,7 @@ describe("Password generator options builder", () => { 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(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); policy.specialCount = 5; // arbitrary value greater than minSpecial expect(minSpecial).toBeLessThan(policy.specialCount); @@ -488,7 +555,7 @@ describe("Password generator options builder", () => { 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 policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(minSpecial).toBeGreaterThanOrEqual(builder.minSpecialCharacters.min); expect(minSpecial).toBeLessThanOrEqual(builder.minSpecialCharacters.max); @@ -504,7 +571,7 @@ describe("Password generator options builder", () => { 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 policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(minSpecial).toBeGreaterThan(builder.minSpecialCharacters.max); @@ -517,7 +584,7 @@ describe("Password generator options builder", () => { ); it("should preserve unknown properties", () => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ unknown: "property", @@ -540,7 +607,7 @@ describe("Password generator options builder", () => { ])( "should output `options.minLowercase === %i` when `options.lowercase` is %s", (expectedMinLowercase, lowercase) => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ lowercase, ...defaultOptions }); @@ -556,7 +623,7 @@ describe("Password generator options builder", () => { ])( "should output `options.minUppercase === %i` when `options.uppercase` is %s", (expectedMinUppercase, uppercase) => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ uppercase, ...defaultOptions }); @@ -572,7 +639,7 @@ describe("Password generator options builder", () => { ])( "should output `options.minNumber === %i` when `options.number` is %s and `options.minNumber` is not set", (expectedMinNumber, number) => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ number, ...defaultOptions }); @@ -590,7 +657,7 @@ describe("Password generator options builder", () => { ])( "should output `options.number === %s` when `options.minNumber` is %i and `options.number` is not set", (expectedNumber, minNumber) => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ minNumber, ...defaultOptions }); @@ -606,7 +673,7 @@ describe("Password generator options builder", () => { ])( "should output `options.minSpecial === %i` when `options.special` is %s and `options.minSpecial` is not set", (special, expectedMinSpecial) => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ special, ...defaultOptions }); @@ -624,7 +691,7 @@ describe("Password generator options builder", () => { ])( "should output `options.special === %s` when `options.minSpecial` is %i and `options.special` is not set", (minSpecial, expectedSpecial) => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ minSpecial, ...defaultOptions }); @@ -645,7 +712,7 @@ describe("Password generator options builder", () => { const sumOfMinimums = minLowercase + minUppercase + minNumber + minSpecial; expect(sumOfMinimums).toBeLessThan(DefaultBoundaries.length.min); - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ minLowercase, @@ -670,7 +737,7 @@ describe("Password generator options builder", () => { (expectedMinLength, minLowercase, minUppercase, minNumber, minSpecial) => { expect(expectedMinLength).toBeGreaterThanOrEqual(DefaultBoundaries.length.min); - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ minLowercase, @@ -687,7 +754,7 @@ describe("Password generator options builder", () => { ); it("should preserve unknown properties", () => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ 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 index 0b3aae57ab..79cb0a9b8e 100644 --- a/libs/common/src/tools/generator/password/password-generator-options-evaluator.ts +++ b/libs/common/src/tools/generator/password/password-generator-options-evaluator.ts @@ -1,6 +1,7 @@ -import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options"; +import { PolicyEvaluator } from "../abstractions/policy-evaluator.abstraction"; -import { PasswordGenerationOptions } from "./password-generator-options"; +import { PasswordGenerationOptions } from "./password-generation-options"; +import { PasswordGeneratorPolicy } from "./password-generator-policy"; function initializeBoundaries() { const length = Object.freeze({ @@ -37,7 +38,9 @@ type Boundary = { /** Enforces policy for password generation. */ -export class PasswordGeneratorOptionsEvaluator { +export class PasswordGeneratorOptionsEvaluator + implements PolicyEvaluator +{ // 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". @@ -62,13 +65,13 @@ export class PasswordGeneratorOptionsEvaluator { /** Policy applied by the evaluator. */ - readonly policy: PasswordGeneratorPolicyOptions; + readonly policy: PasswordGeneratorPolicy; /** Instantiates the evaluator. * @param policy The policy applied by the evaluator. When this conflicts with * the defaults, the policy takes precedence. */ - constructor(policy: PasswordGeneratorPolicyOptions) { + constructor(policy: PasswordGeneratorPolicy) { function createBoundary(value: number, defaultBoundary: Boundary): Boundary { const boundary = { min: Math.max(defaultBoundary.min, value), @@ -78,7 +81,7 @@ export class PasswordGeneratorOptionsEvaluator { return boundary; } - this.policy = policy.clone(); + this.policy = structuredClone(policy); this.minDigits = createBoundary(policy.numberCount, DefaultBoundaries.minDigits); this.minSpecialCharacters = createBoundary( policy.specialCount, @@ -96,12 +99,22 @@ export class PasswordGeneratorOptionsEvaluator { }; } - /** 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. - */ + /** {@link PolicyEvaluator.policyInEffect} */ + get policyInEffect(): boolean { + const policies = [ + this.policy.useUppercase, + this.policy.useLowercase, + this.policy.useNumbers, + this.policy.useSpecial, + this.policy.minLength > DefaultBoundaries.length.min, + this.policy.numberCount > DefaultBoundaries.minDigits.min, + this.policy.specialCount > DefaultBoundaries.minSpecialCharacters.min, + ]; + + return policies.includes(true); + } + + /** {@link PolicyEvaluator.applyPolicy} */ applyPolicy(options: PasswordGenerationOptions): PasswordGenerationOptions { function fitToBounds(value: number, boundaries: Boundary) { const { min, max } = boundaries; @@ -137,13 +150,7 @@ export class PasswordGeneratorOptionsEvaluator { }; } - /** 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. - */ + /** {@link PolicyEvaluator.sanitize} */ sanitize(options: PasswordGenerationOptions): PasswordGenerationOptions { function cascade(enabled: boolean, value: number): [boolean, number] { const enabledResult = enabled ?? value > 0; 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 0f55f8cbc7..f53fffa199 100644 --- a/libs/common/src/tools/generator/password/password-generator-options.ts +++ b/libs/common/src/tools/generator/password/password-generator-options.ts @@ -1,3 +1,5 @@ +import { PasswordGenerationOptions } from "./password-generation-options"; + /** Request format for credential generation. * This type includes all properties suitable for reactive data binding. */ @@ -12,71 +14,6 @@ export type PasswordGeneratorOptions = PasswordGenerationOptions & 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; -}; - /** Request format for passphrase credential generation. * The members of this type may be `undefined` when the user is * generating a password. diff --git a/libs/common/src/tools/generator/password/password-generator-policy.ts b/libs/common/src/tools/generator/password/password-generator-policy.ts new file mode 100644 index 0000000000..c28631e9de --- /dev/null +++ b/libs/common/src/tools/generator/password/password-generator-policy.ts @@ -0,0 +1,50 @@ +/** Policy options enforced during password generation. */ +export type PasswordGeneratorPolicy = { + /** 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. + */ + minLength: number; + + /** When this is true, an uppercase character must be part of + * the generated password. + */ + useUppercase: boolean; + + /** When this is true, a lowercase character must be part of + * the generated password. + */ + useLowercase: boolean; + + /** When this is true, at least one digit must be part of the generated + * password. + */ + useNumbers: boolean; + + /** The quantity of digits to include in the generated password. + * When this is less than or equal to zero, it is ignored. + */ + numberCount: number; + + /** When this is true, at least one digit must be part of the generated + * password. + */ + useSpecial: boolean; + + /** The quantity of special characters to include in the generated + * password. When this is less than or equal to zero, it is ignored. + */ + specialCount: number; +}; + +/** The default options for password generation policy. */ +export const DisabledPasswordGeneratorPolicy: PasswordGeneratorPolicy = Object.freeze({ + minLength: 0, + useUppercase: false, + useLowercase: false, + useNumbers: false, + numberCount: 0, + useSpecial: false, + specialCount: 0, +}); diff --git a/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts b/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts new file mode 100644 index 0000000000..7340ae8fb8 --- /dev/null +++ b/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts @@ -0,0 +1,108 @@ +/** + * include structuredClone in test environment. + * @jest-environment ../../../../shared/test.environment.ts + */ + +import { mock } from "jest-mock-extended"; + +import { PolicyType } from "../../../admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "../../../admin-console/models/domain/policy"; +import { PASSWORD_SETTINGS } from "../key-definitions"; + +import { + PasswordGenerationServiceAbstraction, + PasswordGeneratorOptionsEvaluator, + PasswordGeneratorStrategy, +} from "."; + +describe("Password generation strategy", () => { + describe("evaluator()", () => { + it("should throw if the policy type is incorrect", () => { + const strategy = new PasswordGeneratorStrategy(null); + const policy = mock({ + type: PolicyType.DisableSend, + }); + + expect(() => strategy.evaluator(policy)).toThrow(new RegExp("Mismatched policy type\\. .+")); + }); + + it("should map to the policy evaluator", () => { + const strategy = new PasswordGeneratorStrategy(null); + const policy = mock({ + type: PolicyType.PasswordGenerator, + data: { + minLength: 10, + useUpper: true, + useLower: true, + useNumbers: true, + minNumbers: 1, + useSpecial: true, + minSpecial: 1, + }, + }); + + const evaluator = strategy.evaluator(policy); + + expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator); + expect(evaluator.policy).toMatchObject({ + minLength: 10, + useUppercase: true, + useLowercase: true, + useNumbers: true, + numberCount: 1, + useSpecial: true, + specialCount: 1, + }); + }); + }); + + describe("disk", () => { + it("should use password settings key", () => { + const legacy = mock(); + const strategy = new PasswordGeneratorStrategy(legacy); + + expect(strategy.disk).toBe(PASSWORD_SETTINGS); + }); + }); + + describe("policy", () => { + it("should use password generator policy", () => { + const legacy = mock(); + const strategy = new PasswordGeneratorStrategy(legacy); + + expect(strategy.policy).toBe(PolicyType.PasswordGenerator); + }); + }); + + describe("generate()", () => { + it("should call the legacy service with the given options", async () => { + const legacy = mock(); + const strategy = new PasswordGeneratorStrategy(legacy); + const options = { + type: "password", + minLength: 1, + useUppercase: true, + useLowercase: true, + useNumbers: true, + numberCount: 1, + useSpecial: true, + specialCount: 1, + }; + + await strategy.generate(options); + + expect(legacy.generatePassword).toHaveBeenCalledWith(options); + }); + + it("should set the generation type to password", async () => { + const legacy = mock(); + const strategy = new PasswordGeneratorStrategy(legacy); + + await strategy.generate({ type: "foo" } as any); + + expect(legacy.generatePassword).toHaveBeenCalledWith({ type: "password" }); + }); + }); +}); diff --git a/libs/common/src/tools/generator/password/password-generator-strategy.ts b/libs/common/src/tools/generator/password/password-generator-strategy.ts new file mode 100644 index 0000000000..70bf3087a8 --- /dev/null +++ b/libs/common/src/tools/generator/password/password-generator-strategy.ts @@ -0,0 +1,60 @@ +import { GeneratorStrategy } from ".."; +import { PolicyType } from "../../../admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "../../../admin-console/models/domain/policy"; +import { PASSWORD_SETTINGS } from "../key-definitions"; + +import { PasswordGenerationOptions } from "./password-generation-options"; +import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction"; +import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator"; +import { PasswordGeneratorPolicy } from "./password-generator-policy"; + +const ONE_MINUTE = 60 * 1000; + +/** {@link GeneratorStrategy} */ +export class PasswordGeneratorStrategy + implements GeneratorStrategy +{ + /** instantiates the password generator strategy. + * @param legacy generates the password + */ + constructor(private legacy: PasswordGenerationServiceAbstraction) {} + + /** {@link GeneratorStrategy.disk} */ + get disk() { + return PASSWORD_SETTINGS; + } + + /** {@link GeneratorStrategy.policy} */ + get policy() { + return PolicyType.PasswordGenerator; + } + + get cache_ms() { + return ONE_MINUTE; + } + + /** {@link GeneratorStrategy.evaluator} */ + evaluator(policy: Policy): PasswordGeneratorOptionsEvaluator { + if (policy.type !== this.policy) { + const details = `Expected: ${this.policy}. Received: ${policy.type}`; + throw Error("Mismatched policy type. " + details); + } + + return new PasswordGeneratorOptionsEvaluator({ + minLength: policy.data.minLength, + useUppercase: policy.data.useUpper, + useLowercase: policy.data.useLower, + useNumbers: policy.data.useNumbers, + numberCount: policy.data.minNumbers, + useSpecial: policy.data.useSpecial, + specialCount: policy.data.minSpecial, + }); + } + + /** {@link GeneratorStrategy.generate} */ + generate(options: PasswordGenerationOptions): Promise { + return this.legacy.generatePassword({ ...options, type: "password" }); + } +}