From a241623fe1ccf23dbd6a8afcc407db4b1801cfd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johnny=20G=C3=A9rard?= Date: Sun, 3 Nov 2024 22:59:59 +0100 Subject: [PATCH 01/15] refactor: mark ZxcvbnInput as type import --- client/src/app/workers/password-strength.worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/app/workers/password-strength.worker.ts b/client/src/app/workers/password-strength.worker.ts index 2b0b26c..cb96598 100644 --- a/client/src/app/workers/password-strength.worker.ts +++ b/client/src/app/workers/password-strength.worker.ts @@ -1,7 +1,7 @@ /// import "https://cdn.jsdelivr.net/npm/zxcvbn@4.4.2/dist/zxcvbn.js"; import { APP_NAME } from "_server/constants/app"; -import { ZxcvbnInput } from "_server/types/zxcvbn-input"; +import type { ZxcvbnInput } from "_server/types/zxcvbn-input"; import type zxcvbn from "zxcvbn"; declare global { From 8f321769769b275df28e849a9fe1a44deb739b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johnny=20G=C3=A9rard?= Date: Tue, 5 Nov 2024 07:11:14 +0100 Subject: [PATCH 02/15] feat: simplify password validation implementation --- .../register-form/register-form.component.ts | 112 ++++-------------- .../app/services/password-strength.service.ts | 70 +++++------ .../app/workers/password-validator-factory.ts | 17 +++ 3 files changed, 77 insertions(+), 122 deletions(-) create mode 100644 client/src/app/workers/password-validator-factory.ts diff --git a/client/src/app/components/register-form/register-form.component.ts b/client/src/app/components/register-form/register-form.component.ts index fa6392f..9275414 100644 --- a/client/src/app/components/register-form/register-form.component.ts +++ b/client/src/app/components/register-form/register-form.component.ts @@ -3,14 +3,12 @@ import { ChangeDetectionStrategy, Component, DestroyRef, - effect, inject, signal, } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { - FormBuilder, - FormControl, + NonNullableFormBuilder, ReactiveFormsModule, Validators, } from "@angular/forms"; @@ -21,10 +19,7 @@ import { MatIconModule } from "@angular/material/icon"; import { MatInputModule } from "@angular/material/input"; import { Router, RouterLink } from "@angular/router"; import { CONFLICT } from "_server/constants/http-status-code"; -import { - PASSWORD_MAX_LENGTH, - ZXCVBN_MIN_SCORE, -} from "_server/constants/password"; +import { PASSWORD_MAX_LENGTH } from "_server/constants/password"; import { ClientSession } from "_server/types/client-session"; import { USERNAME_MAX_LENGTH, @@ -37,6 +32,7 @@ import { NotificationService } from "../../services/notification.service"; import { PasswordStrengthService } from "../../services/password-strength.service"; import { SessionService } from "../../services/session.service"; import { UserMessage } from "../../types/user-message.enum"; +import { passwordValidatorFactory } from "../../workers/password-validator-factory"; import { PasswordFieldComponent } from "../password-field/password-field.component"; import { PasswordStrengthMeterComponent } from "../password-strength-meter/password-strength-meter.component"; @@ -62,85 +58,36 @@ import { PasswordStrengthMeterComponent } from "../password-strength-meter/passw }) export class RegisterFormComponent { readonly USERNAME_MAX_LENGTH = USERNAME_MAX_LENGTH; - form = inject(FormBuilder).group({ - username: [ - "", - [ - Validators.required, - Validators.minLength(USERNAME_MIN_LENGTH), - Validators.maxLength(USERNAME_MAX_LENGTH), - ], - ], - password: [ - "", - [Validators.required, Validators.maxLength(PASSWORD_MAX_LENGTH)], - ], - }); isLoading = signal(false); - + form = inject(NonNullableFormBuilder).group( + { + username: [ + "", + [ + Validators.required, + Validators.minLength(USERNAME_MIN_LENGTH), + Validators.maxLength(USERNAME_MAX_LENGTH), + ], + ], + password: [ + "", + [Validators.required, Validators.maxLength(PASSWORD_MAX_LENGTH)], + ], + }, + { + asyncValidators: passwordValidatorFactory( + inject(PasswordStrengthService), + ), + }, + ); #destroyRef = inject(DestroyRef); #http = inject(HttpClient); #notifier = inject(NotificationService); #router = inject(Router); #session = inject(SessionService); - #passwordStrength = inject(PasswordStrengthService); - #shouldResubmit = false; - - constructor() { - // Send form inputs to password strength validation worker service - this.form.valueChanges - .pipe( - takeUntilDestroyed(), - finalize(() => { - // Reset strength meter when the component is destroyed - this.#passwordStrength.validate("", []); - }), - ) - .subscribe((next) => { - const { username, password } = next; - - if (typeof password !== "string") { - return; - } - const userInputs = username ? [username] : []; - this.#passwordStrength.validate(password, userInputs); - }); - - // Listen for password strength validation results from the worker service - // and set validation errors on the password control - effect( - (): void => { - try { - const result = this.#passwordStrength.result(); - const control = this.form.controls.password; - - if (result.score >= ZXCVBN_MIN_SCORE) { - this.#removeStrengthError(control); - return; - } - - // Add validation error - control.setErrors({ - ...control.errors, - strength: result.feedback.warning || "Vulnerable password", - }); - } finally { - if (this.#shouldResubmit) { - this.#shouldResubmit = false; - this.onSubmit(); - } - } - }, - { allowSignalWrites: true }, - ); - } onSubmit(): void { - if (this.form.invalid || this.isLoading()) return; - if (this.#passwordStrength.isWorkerBusy()) { - this.#shouldResubmit = true; - return; - } + if (!this.form.valid || this.isLoading()) return; this.isLoading.set(true); this.#http @@ -167,13 +114,4 @@ export class RegisterFormComponent { }, }); } - - #removeStrengthError(control: FormControl): void { - if (control.errors === null) return; - - delete control.errors["strength"]; - control.setErrors( - Object.keys(control.errors).length ? control.errors : null, - ); - } } diff --git a/client/src/app/services/password-strength.service.ts b/client/src/app/services/password-strength.service.ts index ac3630c..f8daab6 100644 --- a/client/src/app/services/password-strength.service.ts +++ b/client/src/app/services/password-strength.service.ts @@ -1,6 +1,7 @@ import { Injectable, signal } from "@angular/core"; +import { ValidationErrors } from "@angular/forms"; +import { ZXCVBN_MIN_SCORE } from "_server/constants/password"; import { zxcvbnDefaultResult } from "_server/constants/zxcvbn-default-result"; -import { ZxcvbnInput } from "_server/types/zxcvbn-input"; import { ZxcvbnResult } from "_server/types/zxcvbn-result"; @Injectable({ @@ -8,46 +9,45 @@ import { ZxcvbnResult } from "_server/types/zxcvbn-result"; }) export class PasswordStrengthService { result = signal(zxcvbnDefaultResult); - isWorkerBusy = signal(true); - readonly #worker = new Worker( - new URL("../workers/password-strength.worker.js", import.meta.url), - { type: "module" }, - ); - #workerInput: ZxcvbnInput | null = null; - #isWorkerInitialized = false; - constructor() { - const mainListener = (event: MessageEvent): void => { - this.result.set(event.data); - this.#checkWorkerInput(); - }; + get #worker(): Worker { + return new Worker( + new URL("../workers/password-strength.worker.js", import.meta.url), + { type: "module" }, + ); + } - // Set up initial listener - this.#worker.onmessage = (event: MessageEvent): void => { - console.log(event.data); - this.#isWorkerInitialized = true; - this.#worker.onmessage = mainListener; // Overwrite current listener - this.#checkWorkerInput(); - }; + getValidationErrors(result: ZxcvbnResult): ValidationErrors | null { + return result.score >= ZXCVBN_MIN_SCORE + ? null + : { strength: result.feedback.warning || "Vulnerable password" }; } - validate(password: string, userInputs: string[]): void { - if (this.isWorkerBusy() || !this.#isWorkerInitialized) { - this.#workerInput = { password, userInputs }; - return; - } + async validate( + password: string, + userInputs: string[] = [], + ): Promise { + const worker = this.#worker; - this.isWorkerBusy.set(true); - this.#worker.postMessage({ password, userInputs }); - } + return new Promise((resolve, reject) => { + const validationListener = (event: MessageEvent): void => { + this.result.set(event.data); + resolve(this.getValidationErrors(event.data)); + worker.terminate(); + }; - #checkWorkerInput(): void { - if (this.#workerInput) { - this.#worker.postMessage(this.#workerInput); - this.#workerInput = null; - return; - } + const errorListener = (error: ErrorEvent): void => { + reject(error); + worker.terminate(); + }; - this.isWorkerBusy.set(false); + // Wait for the worker to initialize + worker.onmessage = (event: MessageEvent): void => { + console.log(event.data); + worker.onmessage = validationListener; + worker.onerror = errorListener; + worker.postMessage({ password, userInputs }); + }; + }); } } diff --git a/client/src/app/workers/password-validator-factory.ts b/client/src/app/workers/password-validator-factory.ts new file mode 100644 index 0000000..91322a9 --- /dev/null +++ b/client/src/app/workers/password-validator-factory.ts @@ -0,0 +1,17 @@ +import { AbstractControl, ValidationErrors } from "@angular/forms"; +import { PasswordStrengthService } from "../services/password-strength.service"; + +export const passwordValidatorFactory = ( + passwordStrength: PasswordStrengthService, +) => { + return async (control: AbstractControl): Promise => { + const username = control.get("username")?.value; + const password = control.get("password")?.value; + + if (typeof password !== "string" || typeof username !== "string") + return null; + + const userInputs = username ? [username] : []; + return passwordStrength.validate(password, userInputs); + }; +}; From 629f81125f68ef5d097c31fc3714a0fce985743b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johnny=20G=C3=A9rard?= Date: Tue, 5 Nov 2024 14:12:29 +0100 Subject: [PATCH 03/15] feat: reuse web worker --- .../app/services/password-strength.service.ts | 55 ++++++++++--------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/client/src/app/services/password-strength.service.ts b/client/src/app/services/password-strength.service.ts index f8daab6..9eb3f53 100644 --- a/client/src/app/services/password-strength.service.ts +++ b/client/src/app/services/password-strength.service.ts @@ -3,18 +3,25 @@ import { ValidationErrors } from "@angular/forms"; import { ZXCVBN_MIN_SCORE } from "_server/constants/password"; import { zxcvbnDefaultResult } from "_server/constants/zxcvbn-default-result"; import { ZxcvbnResult } from "_server/types/zxcvbn-result"; +import { BehaviorSubject, first } from "rxjs"; @Injectable({ providedIn: "root", }) export class PasswordStrengthService { result = signal(zxcvbnDefaultResult); + #isReady$ = new BehaviorSubject(false); - get #worker(): Worker { - return new Worker( - new URL("../workers/password-strength.worker.js", import.meta.url), - { type: "module" }, - ); + #worker = new Worker( + new URL("../workers/password-strength.worker.js", import.meta.url), + { type: "module" }, + ); + + constructor() { + this.#worker.onmessage = (event: MessageEvent): void => { + console.log(event.data); + this.#isReady$.next(true); + }; } getValidationErrors(result: ZxcvbnResult): ValidationErrors | null { @@ -27,27 +34,25 @@ export class PasswordStrengthService { password: string, userInputs: string[] = [], ): Promise { - const worker = this.#worker; - return new Promise((resolve, reject) => { - const validationListener = (event: MessageEvent): void => { - this.result.set(event.data); - resolve(this.getValidationErrors(event.data)); - worker.terminate(); - }; - - const errorListener = (error: ErrorEvent): void => { - reject(error); - worker.terminate(); - }; - - // Wait for the worker to initialize - worker.onmessage = (event: MessageEvent): void => { - console.log(event.data); - worker.onmessage = validationListener; - worker.onerror = errorListener; - worker.postMessage({ password, userInputs }); - }; + this.#isReady$.pipe(first((isReady) => isReady)).subscribe(() => { + this.#isReady$.next(false); + + this.#worker.onmessage = (event: MessageEvent): void => { + const result = event.data; + + this.result.set(result); + resolve(this.getValidationErrors(result)); + this.#isReady$.next(true); + }; + + this.#worker.onerror = (error: ErrorEvent): void => { + reject(error); + this.#isReady$.next(true); + }; + + this.#worker.postMessage({ password, userInputs }); + }); }); } } From 469107ee7b3037e0e8fcde22ab696466ca24cac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johnny=20G=C3=A9rard?= Date: Fri, 8 Nov 2024 21:37:18 +0100 Subject: [PATCH 04/15] refactor: rename fetchDictionary to getDictionary --- client/src/app/workers/password-strength.worker.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/app/workers/password-strength.worker.ts b/client/src/app/workers/password-strength.worker.ts index cb96598..56307d9 100644 --- a/client/src/app/workers/password-strength.worker.ts +++ b/client/src/app/workers/password-strength.worker.ts @@ -10,7 +10,7 @@ declare global { } } -const fetchDictionary = async (): Promise => { +const getDictionary = async (): Promise => { const response = await fetch("app-dictionary.txt"); const text = await response.text(); const dictionary = text.split("\n"); @@ -25,7 +25,7 @@ const fetchDictionary = async (): Promise => { * experimental. * @see https://angular.dev/guide/experimental/zoneless */ -fetchDictionary().then((dictionary) => { +getDictionary().then((dictionary) => { self.onmessage = (event: MessageEvent): void => { const { password, userInputs } = event.data; const result = self.zxcvbn(password, userInputs.concat(dictionary)); From 13c71213d9f81b62eb06242118204faa7d59183e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johnny=20G=C3=A9rard?= Date: Fri, 8 Nov 2024 23:30:12 +0100 Subject: [PATCH 05/15] refactor: use observables for async validation --- .../register-form/register-form.component.ts | 2 +- .../app/services/password-strength.service.ts | 61 +++++++++---------- .../validators/password-validator-factory.ts | 28 +++++++++ .../app/workers/password-validator-factory.ts | 17 ------ 4 files changed, 58 insertions(+), 50 deletions(-) create mode 100644 client/src/app/validators/password-validator-factory.ts delete mode 100644 client/src/app/workers/password-validator-factory.ts diff --git a/client/src/app/components/register-form/register-form.component.ts b/client/src/app/components/register-form/register-form.component.ts index 9275414..4f0619b 100644 --- a/client/src/app/components/register-form/register-form.component.ts +++ b/client/src/app/components/register-form/register-form.component.ts @@ -32,7 +32,7 @@ import { NotificationService } from "../../services/notification.service"; import { PasswordStrengthService } from "../../services/password-strength.service"; import { SessionService } from "../../services/session.service"; import { UserMessage } from "../../types/user-message.enum"; -import { passwordValidatorFactory } from "../../workers/password-validator-factory"; +import { passwordValidatorFactory } from "../../validators/password-validator-factory"; import { PasswordFieldComponent } from "../password-field/password-field.component"; import { PasswordStrengthMeterComponent } from "../password-strength-meter/password-strength-meter.component"; diff --git a/client/src/app/services/password-strength.service.ts b/client/src/app/services/password-strength.service.ts index 9eb3f53..1663acd 100644 --- a/client/src/app/services/password-strength.service.ts +++ b/client/src/app/services/password-strength.service.ts @@ -1,17 +1,23 @@ -import { Injectable, signal } from "@angular/core"; -import { ValidationErrors } from "@angular/forms"; -import { ZXCVBN_MIN_SCORE } from "_server/constants/password"; +import { Injectable } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; import { zxcvbnDefaultResult } from "_server/constants/zxcvbn-default-result"; import { ZxcvbnResult } from "_server/types/zxcvbn-result"; -import { BehaviorSubject, first } from "rxjs"; +import { + BehaviorSubject, + first, + Observable, + Subject, + switchMap, + tap, +} from "rxjs"; @Injectable({ providedIn: "root", }) export class PasswordStrengthService { - result = signal(zxcvbnDefaultResult); + #result$ = new Subject(); + result = toSignal(this.#result$, { initialValue: zxcvbnDefaultResult }); #isReady$ = new BehaviorSubject(false); - #worker = new Worker( new URL("../workers/password-strength.worker.js", import.meta.url), { type: "module" }, @@ -20,39 +26,30 @@ export class PasswordStrengthService { constructor() { this.#worker.onmessage = (event: MessageEvent): void => { console.log(event.data); + + this.#worker.onmessage = (event: MessageEvent): void => { + this.#result$.next(event.data); + }; + this.#isReady$.next(true); }; } - getValidationErrors(result: ZxcvbnResult): ValidationErrors | null { - return result.score >= ZXCVBN_MIN_SCORE - ? null - : { strength: result.feedback.warning || "Vulnerable password" }; - } - - async validate( + validate( password: string, userInputs: string[] = [], - ): Promise { - return new Promise((resolve, reject) => { - this.#isReady$.pipe(first((isReady) => isReady)).subscribe(() => { + ): Observable { + return this.#isReady$.pipe( + first(Boolean), + switchMap(() => { this.#isReady$.next(false); - - this.#worker.onmessage = (event: MessageEvent): void => { - const result = event.data; - - this.result.set(result); - resolve(this.getValidationErrors(result)); - this.#isReady$.next(true); - }; - - this.#worker.onerror = (error: ErrorEvent): void => { - reject(error); - this.#isReady$.next(true); - }; - this.#worker.postMessage({ password, userInputs }); - }); - }); + + return this.#result$.pipe( + first(), + tap(() => this.#isReady$.next(true)), + ); + }), + ); } } diff --git a/client/src/app/validators/password-validator-factory.ts b/client/src/app/validators/password-validator-factory.ts new file mode 100644 index 0000000..161c167 --- /dev/null +++ b/client/src/app/validators/password-validator-factory.ts @@ -0,0 +1,28 @@ +import { AbstractControl, ValidationErrors } from "@angular/forms"; +import { ZXCVBN_MIN_SCORE } from "_server/constants/password"; +import { ZxcvbnResult } from "_server/types/zxcvbn-result"; +import { map, Observable, of } from "rxjs"; +import { PasswordStrengthService } from "../services/password-strength.service"; + +const getValidationErrors = (result: ZxcvbnResult): ValidationErrors | null => + result.score >= ZXCVBN_MIN_SCORE + ? null + : { strength: result.feedback.warning || "Vulnerable password" }; + +export const passwordValidatorFactory = ( + passwordStrength: PasswordStrengthService, +) => { + return (control: AbstractControl): Observable => { + const username = control.get("username")?.value; + const password = control.get("password")?.value; + + if (typeof password !== "string" || typeof username !== "string") + return of(null); + + const userInputs = username ? [username] : []; + + return passwordStrength + .validate(password, userInputs) + .pipe(map((result) => getValidationErrors(result))); + }; +}; diff --git a/client/src/app/workers/password-validator-factory.ts b/client/src/app/workers/password-validator-factory.ts deleted file mode 100644 index 91322a9..0000000 --- a/client/src/app/workers/password-validator-factory.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { AbstractControl, ValidationErrors } from "@angular/forms"; -import { PasswordStrengthService } from "../services/password-strength.service"; - -export const passwordValidatorFactory = ( - passwordStrength: PasswordStrengthService, -) => { - return async (control: AbstractControl): Promise => { - const username = control.get("username")?.value; - const password = control.get("password")?.value; - - if (typeof password !== "string" || typeof username !== "string") - return null; - - const userInputs = username ? [username] : []; - return passwordStrength.validate(password, userInputs); - }; -}; From 9f806c569abc10196fc5fec3456373269f7deb1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johnny=20G=C3=A9rard?= Date: Fri, 8 Nov 2024 23:39:02 +0100 Subject: [PATCH 06/15] fix: strength meter does not reset --- .../password-strength-meter.component.ts | 7 ++++++- client/src/app/services/password-strength.service.ts | 9 +++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/client/src/app/components/password-strength-meter/password-strength-meter.component.ts b/client/src/app/components/password-strength-meter/password-strength-meter.component.ts index 162681f..c583e93 100644 --- a/client/src/app/components/password-strength-meter/password-strength-meter.component.ts +++ b/client/src/app/components/password-strength-meter/password-strength-meter.component.ts @@ -4,8 +4,10 @@ import { computed, inject, } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; import { MatIconModule } from "@angular/material/icon"; import { MatTooltipModule } from "@angular/material/tooltip"; +import { zxcvbnDefaultResult } from "_server/constants/zxcvbn-default-result"; import { PasswordStrengthService } from "../../services/password-strength.service"; @Component({ @@ -17,7 +19,10 @@ import { PasswordStrengthService } from "../../services/password-strength.servic changeDetection: ChangeDetectionStrategy.OnPush, }) export class PasswordStrengthMeterComponent { - result = inject(PasswordStrengthService).result; + result = toSignal(inject(PasswordStrengthService).result$, { + initialValue: zxcvbnDefaultResult, + }); + score = computed(() => this.result().score); ariaValueText = computed(() => this.#scoreLabel); isPasswordValid = computed(() => this.score() >= 3); diff --git a/client/src/app/services/password-strength.service.ts b/client/src/app/services/password-strength.service.ts index 1663acd..bf1259c 100644 --- a/client/src/app/services/password-strength.service.ts +++ b/client/src/app/services/password-strength.service.ts @@ -1,6 +1,4 @@ import { Injectable } from "@angular/core"; -import { toSignal } from "@angular/core/rxjs-interop"; -import { zxcvbnDefaultResult } from "_server/constants/zxcvbn-default-result"; import { ZxcvbnResult } from "_server/types/zxcvbn-result"; import { BehaviorSubject, @@ -15,8 +13,7 @@ import { providedIn: "root", }) export class PasswordStrengthService { - #result$ = new Subject(); - result = toSignal(this.#result$, { initialValue: zxcvbnDefaultResult }); + result$ = new Subject(); #isReady$ = new BehaviorSubject(false); #worker = new Worker( new URL("../workers/password-strength.worker.js", import.meta.url), @@ -28,7 +25,7 @@ export class PasswordStrengthService { console.log(event.data); this.#worker.onmessage = (event: MessageEvent): void => { - this.#result$.next(event.data); + this.result$.next(event.data); }; this.#isReady$.next(true); @@ -45,7 +42,7 @@ export class PasswordStrengthService { this.#isReady$.next(false); this.#worker.postMessage({ password, userInputs }); - return this.#result$.pipe( + return this.result$.pipe( first(), tap(() => this.#isReady$.next(true)), ); From bb75efc3daf27c354b06d9051d7aa67284602826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johnny=20G=C3=A9rard?= Date: Sat, 9 Nov 2024 13:04:05 +0100 Subject: [PATCH 07/15] fix: use single-field validation instead of cross-field --- .../password-field.component.ts | 26 ++++++++- .../register-form.component.html | 6 +- .../register-form/register-form.component.ts | 58 ++++++++++++------- .../validators/password-validator-factory.ts | 11 ++-- 4 files changed, 70 insertions(+), 31 deletions(-) diff --git a/client/src/app/components/password-field/password-field.component.ts b/client/src/app/components/password-field/password-field.component.ts index 90280c0..c881a5c 100644 --- a/client/src/app/components/password-field/password-field.component.ts +++ b/client/src/app/components/password-field/password-field.component.ts @@ -2,9 +2,13 @@ import { ChangeDetectionStrategy, Component, computed, + DestroyRef, + inject, input, + OnInit, signal, } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl, ReactiveFormsModule } from "@angular/forms"; import { MatButtonModule } from "@angular/material/button"; import { MatFormFieldModule } from "@angular/material/form-field"; @@ -12,7 +16,9 @@ import { MatIconModule } from "@angular/material/icon"; import { MatInputModule } from "@angular/material/input"; import { MatTooltipModule } from "@angular/material/tooltip"; import { PASSWORD_MAX_LENGTH } from "_server/constants/password"; +import { zxcvbnDefaultResult } from "_server/constants/zxcvbn-default-result"; import { PasswordErrorPipe } from "../../pipes/password-error.pipe"; +import { PasswordStrengthService } from "../../services/password-strength.service"; @Component({ selector: "app-password-field", @@ -33,7 +39,7 @@ import { PasswordErrorPipe } from "../../pipes/password-error.pipe"; // https://stackoverflow.com/questions/56584244/can-the-angular-material-errorstatematcher-detect-when-a-parent-form-is-submitte changeDetection: ChangeDetectionStrategy.Default, }) -export class PasswordFieldComponent { +export class PasswordFieldComponent implements OnInit { readonly PASSWORD_MAX_LENGTH = PASSWORD_MAX_LENGTH; autocomplete = input.required<"new-password" | "current-password">(); isPasswordVisible = signal(false); @@ -42,6 +48,24 @@ export class PasswordFieldComponent { () => `${this.isPasswordVisible() ? "Hide" : "Show"} password`, ); + #destroyRef = inject(DestroyRef); + #strength = inject(PasswordStrengthService); + + /** + * Reset the password strength meter when the password input is empty. + * + * This is necessary because the "required" validator prevents the password + * strength async validator from running when the input is empty. + * @see https://angular.dev/guide/forms/form-validation#creating-asynchronous-validators + */ + ngOnInit(): void { + this.passwordControl() + .valueChanges.pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe((value) => { + if (value === "") this.#strength.result$.next(zxcvbnDefaultResult); + }); + } + togglePasswordVisibility(event: MouseEvent): void { this.isPasswordVisible.update((value) => !value); diff --git a/client/src/app/components/register-form/register-form.component.html b/client/src/app/components/register-form/register-form.component.html index c6f6a14..c18b131 100644 --- a/client/src/app/components/register-form/register-form.component.html +++ b/client/src/app/components/register-form/register-form.component.html @@ -18,13 +18,11 @@

Create your account

spellcheck="false" type="text" /> - {{ form.controls.username.errors | usernameError }} - + {{ usernameControl.errors | usernameError }} diff --git a/client/src/app/components/register-form/register-form.component.ts b/client/src/app/components/register-form/register-form.component.ts index 4f0619b..ad49a3b 100644 --- a/client/src/app/components/register-form/register-form.component.ts +++ b/client/src/app/components/register-form/register-form.component.ts @@ -8,6 +8,7 @@ import { } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { + FormControl, NonNullableFormBuilder, ReactiveFormsModule, Validators, @@ -59,33 +60,48 @@ import { PasswordStrengthMeterComponent } from "../password-strength-meter/passw export class RegisterFormComponent { readonly USERNAME_MAX_LENGTH = USERNAME_MAX_LENGTH; isLoading = signal(false); - form = inject(NonNullableFormBuilder).group( - { - username: [ - "", - [ - Validators.required, - Validators.minLength(USERNAME_MIN_LENGTH), - Validators.maxLength(USERNAME_MAX_LENGTH), - ], - ], - password: [ - "", - [Validators.required, Validators.maxLength(PASSWORD_MAX_LENGTH)], - ], - }, - { - asyncValidators: passwordValidatorFactory( - inject(PasswordStrengthService), - ), - }, - ); + + usernameControl = new FormControl("", { + nonNullable: true, + validators: [ + Validators.required, + Validators.minLength(USERNAME_MIN_LENGTH), + Validators.maxLength(USERNAME_MAX_LENGTH), + ], + }); + + passwordControl = new FormControl("", { + nonNullable: true, + validators: [ + Validators.required, + Validators.maxLength(PASSWORD_MAX_LENGTH), + ], + asyncValidators: passwordValidatorFactory( + inject(PasswordStrengthService), + this.usernameControl, + ), + }); + + form = inject(NonNullableFormBuilder).group({ + username: this.usernameControl, + password: this.passwordControl, + }); + #destroyRef = inject(DestroyRef); #http = inject(HttpClient); #notifier = inject(NotificationService); #router = inject(Router); #session = inject(SessionService); + constructor() { + // Re-evaluate the password strength whenever the username changes + this.usernameControl.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(() => { + this.passwordControl.updateValueAndValidity(); + }); + } + onSubmit(): void { if (!this.form.valid || this.isLoading()) return; this.isLoading.set(true); diff --git a/client/src/app/validators/password-validator-factory.ts b/client/src/app/validators/password-validator-factory.ts index 161c167..0e6ac84 100644 --- a/client/src/app/validators/password-validator-factory.ts +++ b/client/src/app/validators/password-validator-factory.ts @@ -11,15 +11,16 @@ const getValidationErrors = (result: ZxcvbnResult): ValidationErrors | null => export const passwordValidatorFactory = ( passwordStrength: PasswordStrengthService, + ...otherControls: AbstractControl[] ) => { return (control: AbstractControl): Observable => { - const username = control.get("username")?.value; - const password = control.get("password")?.value; + const password = control.value; - if (typeof password !== "string" || typeof username !== "string") - return of(null); + if (typeof password !== "string") return of(null); - const userInputs = username ? [username] : []; + const userInputs = otherControls + .map((control) => control.value) + .filter((value) => value && typeof value === "string") as string[]; return passwordStrength .validate(password, userInputs) From e97f6b1ff3748dadea7bc42f3ae61c68cd88e3fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johnny=20G=C3=A9rard?= Date: Sat, 9 Nov 2024 13:48:03 +0100 Subject: [PATCH 08/15] feat: remove redundant UsernameValidatorDirective --- .../register-form.component.html | 3 +- .../register-form/register-form.component.ts | 2 -- .../sign-in-form/sign-in-form.component.html | 1 - .../sign-in-form/sign-in-form.component.ts | 2 -- .../username-validator.directive.ts | 29 ------------------- 5 files changed, 1 insertion(+), 36 deletions(-) delete mode 100644 client/src/app/directives/username-validator.directive.ts diff --git a/client/src/app/components/register-form/register-form.component.html b/client/src/app/components/register-form/register-form.component.html index c18b131..196631a 100644 --- a/client/src/app/components/register-form/register-form.component.html +++ b/client/src/app/components/register-form/register-form.component.html @@ -10,7 +10,6 @@

Create your account

Username Create your account spellcheck="false" type="text" /> - {{ usernameControl.errors | usernameError }} + {{ usernameControl.errors | usernameError }} Sign in to your account Username Date: Sat, 9 Nov 2024 13:48:30 +0100 Subject: [PATCH 09/15] feat: add usernamePatternValidator function --- .../register-form/register-form.component.ts | 2 ++ .../src/app/validators/username-pattern-validator.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 client/src/app/validators/username-pattern-validator.ts diff --git a/client/src/app/components/register-form/register-form.component.ts b/client/src/app/components/register-form/register-form.component.ts index b583a3c..73c3461 100644 --- a/client/src/app/components/register-form/register-form.component.ts +++ b/client/src/app/components/register-form/register-form.component.ts @@ -33,6 +33,7 @@ import { PasswordStrengthService } from "../../services/password-strength.servic import { SessionService } from "../../services/session.service"; import { UserMessage } from "../../types/user-message.enum"; import { passwordValidatorFactory } from "../../validators/password-validator-factory"; +import { usernamePatternValidator } from "../../validators/username-pattern-validator"; import { PasswordFieldComponent } from "../password-field/password-field.component"; import { PasswordStrengthMeterComponent } from "../password-strength-meter/password-strength-meter.component"; @@ -65,6 +66,7 @@ export class RegisterFormComponent { Validators.required, Validators.minLength(USERNAME_MIN_LENGTH), Validators.maxLength(USERNAME_MAX_LENGTH), + usernamePatternValidator, ], }); diff --git a/client/src/app/validators/username-pattern-validator.ts b/client/src/app/validators/username-pattern-validator.ts new file mode 100644 index 0000000..f72461d --- /dev/null +++ b/client/src/app/validators/username-pattern-validator.ts @@ -0,0 +1,12 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms"; +import { hasValidUsernameCharacters } from "_server/validation/username"; + +export const usernamePatternValidator: ValidatorFn = ( + control: AbstractControl, +): ValidationErrors | null => { + const username = control.value; + + return typeof username === "string" && !hasValidUsernameCharacters(username) + ? { pattern: "Invalid characters" } + : null; +}; From 8c7462a5dc14c7292a6f58d1c898c0765934cca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johnny=20G=C3=A9rard?= Date: Sat, 9 Nov 2024 13:50:18 +0100 Subject: [PATCH 10/15] feat: remove username minlength validation in login form --- .../components/sign-in-form/sign-in-form.component.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/client/src/app/components/sign-in-form/sign-in-form.component.ts b/client/src/app/components/sign-in-form/sign-in-form.component.ts index 0e45562..e233022 100644 --- a/client/src/app/components/sign-in-form/sign-in-form.component.ts +++ b/client/src/app/components/sign-in-form/sign-in-form.component.ts @@ -18,10 +18,7 @@ import { UNAUTHORIZED } from "_server/constants/http-status-code"; import { PASSWORD_MAX_LENGTH } from "_server/constants/password"; import { ApiError } from "_server/types/api-error.enum"; import { ClientSession } from "_server/types/client-session"; -import { - USERNAME_MAX_LENGTH, - USERNAME_MIN_LENGTH, -} from "_server/validation/username"; +import { USERNAME_MAX_LENGTH } from "_server/validation/username"; import { finalize } from "rxjs"; import { UsernameErrorPipe } from "../../pipes/username-error.pipe"; import { NotificationService } from "../../services/notification.service"; @@ -52,11 +49,7 @@ export class SignInFormComponent { form = inject(FormBuilder).group({ username: [ "", - [ - Validators.required, - Validators.minLength(USERNAME_MIN_LENGTH), - Validators.maxLength(USERNAME_MAX_LENGTH), - ], + [Validators.required, Validators.maxLength(USERNAME_MAX_LENGTH)], ], password: [ "", From 05fa803f9a67b16e4a6fcbe0d284d975401e5cc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johnny=20G=C3=A9rard?= Date: Sat, 9 Nov 2024 18:19:43 +0100 Subject: [PATCH 11/15] refactor: introduce mainListener constant --- client/src/app/services/password-strength.service.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/app/services/password-strength.service.ts b/client/src/app/services/password-strength.service.ts index bf1259c..73a9f82 100644 --- a/client/src/app/services/password-strength.service.ts +++ b/client/src/app/services/password-strength.service.ts @@ -21,13 +21,13 @@ export class PasswordStrengthService { ); constructor() { + const mainListener = (event: MessageEvent): void => { + this.result$.next(event.data); + }; + this.#worker.onmessage = (event: MessageEvent): void => { console.log(event.data); - - this.#worker.onmessage = (event: MessageEvent): void => { - this.result$.next(event.data); - }; - + this.#worker.onmessage = mainListener; this.#isReady$.next(true); }; } From 81368ce61ce3e50d94222889f75627af64945dfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johnny=20G=C3=A9rard?= Date: Sun, 10 Nov 2024 12:32:14 +0100 Subject: [PATCH 12/15] feat: add Queue type --- client/src/app/types/queue.class.ts | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 client/src/app/types/queue.class.ts diff --git a/client/src/app/types/queue.class.ts b/client/src/app/types/queue.class.ts new file mode 100644 index 0000000..0d4935a --- /dev/null +++ b/client/src/app/types/queue.class.ts @@ -0,0 +1,37 @@ +export class Queue { + readonly #items: T[]; + + constructor(...items: T[]) { + this.#items = items; + } + + get isEmpty(): boolean { + return this.#items.length === 0; + } + + get size(): number { + return this.#items.length; + } + + enqueue(item: T): void { + try { + this.#items.push(item); + } catch (e) { + throw new Error("Failed to enqueue item", { cause: e }); + } + } + + dequeue(): T { + if (this.isEmpty) throw new Error("Cannot dequeue from an empty queue"); + return this.#items.shift() as T; + } + + peek(): T { + if (this.isEmpty) throw new Error("Cannot peek at an empty queue"); + return this.#items[0]; + } + + toString(): string { + return `Queue(${this.#items.join(", ")})`; + } +} From dfbe55ae6d4ce402fc09d5afe1359e782cbf695f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johnny=20G=C3=A9rard?= Date: Sun, 10 Nov 2024 12:32:42 +0100 Subject: [PATCH 13/15] feat: use queue in PasswordStrengthService --- .../app/services/password-strength.service.ts | 66 ++++++++++++------- 1 file changed, 44 insertions(+), 22 deletions(-) diff --git a/client/src/app/services/password-strength.service.ts b/client/src/app/services/password-strength.service.ts index 73a9f82..4534c29 100644 --- a/client/src/app/services/password-strength.service.ts +++ b/client/src/app/services/password-strength.service.ts @@ -1,20 +1,21 @@ import { Injectable } from "@angular/core"; +import { ZxcvbnInput } from "_server/types/zxcvbn-input"; import { ZxcvbnResult } from "_server/types/zxcvbn-result"; -import { - BehaviorSubject, - first, - Observable, - Subject, - switchMap, - tap, -} from "rxjs"; +import { Observable, Subject } from "rxjs"; +import { Queue } from "../types/queue.class"; @Injectable({ providedIn: "root", }) export class PasswordStrengthService { result$ = new Subject(); - #isReady$ = new BehaviorSubject(false); + #isWorkerInitialized = false; + + #queue = new Queue<{ + input: ZxcvbnInput; + result$: Subject; + }>(); + #worker = new Worker( new URL("../workers/password-strength.worker.js", import.meta.url), { type: "module" }, @@ -23,12 +24,25 @@ export class PasswordStrengthService { constructor() { const mainListener = (event: MessageEvent): void => { this.result$.next(event.data); + + if (this.#queue.size === 1) { + const { result$ } = this.#queue.dequeue(); + + result$.next(event.data); + result$.complete(); + return; + } + + this.#handleWork(); }; this.#worker.onmessage = (event: MessageEvent): void => { console.log(event.data); this.#worker.onmessage = mainListener; - this.#isReady$.next(true); + this.#isWorkerInitialized = true; + + if (this.#queue.isEmpty) return; + this.#handleWork(); }; } @@ -36,17 +50,25 @@ export class PasswordStrengthService { password: string, userInputs: string[] = [], ): Observable { - return this.#isReady$.pipe( - first(Boolean), - switchMap(() => { - this.#isReady$.next(false); - this.#worker.postMessage({ password, userInputs }); - - return this.result$.pipe( - first(), - tap(() => this.#isReady$.next(true)), - ); - }), - ); + const input: ZxcvbnInput = { password, userInputs }; + const result$ = new Subject(); + + this.#queue.enqueue({ input, result$ }); + + if (this.#queue.size === 1 && this.#isWorkerInitialized) + this.#worker.postMessage(input); + + return result$.asObservable(); + } + + /** + * Handle work items that were enqueued while the worker was busy. + */ + #handleWork(): void { + // Abort all previous requests + while (this.#queue.size > 1) this.#queue.dequeue().result$.complete(); + + // Send the last user input to the worker + this.#worker.postMessage(this.#queue.peek().input); } } From e7b013d93863c223ffaad30db5ca0d5f1a2a34d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johnny=20G=C3=A9rard?= Date: Sun, 10 Nov 2024 13:20:59 +0100 Subject: [PATCH 14/15] feat: add worker error handling --- .../app/services/password-strength.service.ts | 50 +++++++++++-------- client/src/app/types/user-message.enum.ts | 1 + 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/client/src/app/services/password-strength.service.ts b/client/src/app/services/password-strength.service.ts index 4534c29..052499f 100644 --- a/client/src/app/services/password-strength.service.ts +++ b/client/src/app/services/password-strength.service.ts @@ -1,8 +1,10 @@ -import { Injectable } from "@angular/core"; +import { inject, Injectable } from "@angular/core"; import { ZxcvbnInput } from "_server/types/zxcvbn-input"; import { ZxcvbnResult } from "_server/types/zxcvbn-result"; import { Observable, Subject } from "rxjs"; import { Queue } from "../types/queue.class"; +import { UserMessage } from "../types/user-message.enum"; +import { NotificationService } from "./notification.service"; @Injectable({ providedIn: "root", @@ -10,18 +12,35 @@ import { Queue } from "../types/queue.class"; export class PasswordStrengthService { result$ = new Subject(); #isWorkerInitialized = false; + #notifier = inject(NotificationService); + #worker = this.#createWorker(); #queue = new Queue<{ input: ZxcvbnInput; result$: Subject; }>(); - #worker = new Worker( - new URL("../workers/password-strength.worker.js", import.meta.url), - { type: "module" }, - ); + validate( + password: string, + userInputs: string[] = [], + ): Observable { + const input: ZxcvbnInput = { password, userInputs }; + const result$ = new Subject(); + + this.#queue.enqueue({ input, result$ }); + + if (this.#queue.size === 1 && this.#isWorkerInitialized) + this.#worker.postMessage(input); + + return result$.asObservable(); + } + + #createWorker(): Worker { + const worker = new Worker( + new URL("../workers/password-strength.worker.js", import.meta.url), + { type: "module" }, + ); - constructor() { const mainListener = (event: MessageEvent): void => { this.result$.next(event.data); @@ -36,7 +55,7 @@ export class PasswordStrengthService { this.#handleWork(); }; - this.#worker.onmessage = (event: MessageEvent): void => { + worker.onmessage = (event: MessageEvent): void => { console.log(event.data); this.#worker.onmessage = mainListener; this.#isWorkerInitialized = true; @@ -44,21 +63,12 @@ export class PasswordStrengthService { if (this.#queue.isEmpty) return; this.#handleWork(); }; - } - - validate( - password: string, - userInputs: string[] = [], - ): Observable { - const input: ZxcvbnInput = { password, userInputs }; - const result$ = new Subject(); - - this.#queue.enqueue({ input, result$ }); - if (this.#queue.size === 1 && this.#isWorkerInitialized) - this.#worker.postMessage(input); + worker.onerror = (): void => { + this.#notifier.send(UserMessage.UNEXPECTED_WORKER_ERROR); + }; - return result$.asObservable(); + return worker; } /** diff --git a/client/src/app/types/user-message.enum.ts b/client/src/app/types/user-message.enum.ts index b8e1da7..f2d3169 100644 --- a/client/src/app/types/user-message.enum.ts +++ b/client/src/app/types/user-message.enum.ts @@ -16,5 +16,6 @@ export const enum UserMessage { SERVICE_UNAVAILABLE = "Sorry, the server is currently unavailable. Please try again later.", SESSIONS_PAGE_LOAD_FAILED = "Failed to load session data. Please try reloading the page.", UNEXPECTED_ERROR = "An unknown error has occurred. Please try again later.", + UNEXPECTED_WORKER_ERROR = "An unknown error has occurred. Please try reloading the page.", USERNAME_TAKEN = "Sorry, this username is not available.", } From 9b4d074c79f764092f61be383466059570fe0617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johnny=20G=C3=A9rard?= Date: Sun, 10 Nov 2024 13:43:51 +0100 Subject: [PATCH 15/15] fix: failing test in register-user.function.ts --- client/e2e/register-user.function.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/e2e/register-user.function.ts b/client/e2e/register-user.function.ts index 10cffaf..c14b08d 100644 --- a/client/e2e/register-user.function.ts +++ b/client/e2e/register-user.function.ts @@ -25,6 +25,8 @@ export const registerUser = async ( await usernameInput.fill(username); await passwordInput.fill(password); + // Wait for asynchronous validation to complete + await expect(passwordInput).toHaveClass(/ng-valid/); await page.keyboard.press("Enter"); await responsePromise.then((response) => {