Skip to content

Commit

Permalink
Merge pull request #102 from johnnygerard/update-password
Browse files Browse the repository at this point in the history
feat: add password update form
  • Loading branch information
johnnygerard authored Nov 12, 2024
2 parents 927fe9e + 8b95fd8 commit cc660d8
Show file tree
Hide file tree
Showing 27 changed files with 849 additions and 53 deletions.
35 changes: 26 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,33 +28,50 @@ you may experience a few seconds of delay on the first API request.
- **Database**: User data stored in [MongoDB Atlas](https://www.mongodb.com/atlas).
- **Cache**: Session data managed by [Redis Cloud](https://redis.io/cloud/).

## Key Features
## Features

### Security
### High-Level

- User login/registration with username and password
- Logout and session revocation
- Password update
- Account deletion with password confirmation

- Username and password authentication
- Password strength validation with [zxcvbn](https://github.com/dropbox/zxcvbn?tab=readme-ov-file#readme)
- Password hashing using [Argon2](https://github.com/P-H-C/phc-winner-argon2?tab=readme-ov-file#readme) algorithm
### Password

- Strength validation with [zxcvbn](https://github.com/dropbox/zxcvbn?tab=readme-ov-file#readme)
- [Argon2](https://github.com/P-H-C/phc-winner-argon2?tab=readme-ov-file#readme) hashing algorithm
- [Pwned Passwords API](https://haveibeenpwned.com/API/v3#PwnedPasswords) validation
- Cookie-based server-side session authentication

### Security

- Session authentication with encrypted cookies
- Session data stored in Redis Cloud
- CSRF protection using
the [synchronizer token pattern](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#synchronizer-token-pattern)
- Rate limiting with [express-rate-limit](https://express-rate-limit.mintlify.app/overview)
- CORS-enabled REST API

### Performance

- [Prerendering (SSG)](https://angular.dev/guide/prerendering)
- Lazy-loaded components
- `OnPush` change detection strategy
- Web workers for CPU-intensive tasks
- Worker threads for CPU-intensive tasks

### Testing

- Server-side testing with [Node.js test runner](https://nodejs.org/api/test.html#test-runner)
- [Playwright](https://playwright.dev/) end-to-end testing
- Test data generated by [Faker](https://fakerjs.dev/)

## Version Requirements

- Angular 18
- Node.js 22
- Express 4
- MongoDB Atlas 7
- Redis Stack 7.4

## Lighthouse Reports

Version audited: v0.19.0
Expand All @@ -65,7 +82,7 @@ Version audited: v0.19.0

## Dev Environment & Tools

- System: [Ubuntu](https://ubuntu.com/)
- System: [Ubuntu](https://ubuntu.com/desktop)
- IDE: [WebStorm](https://www.jetbrains.com/webstorm/)
- Formatter: [Prettier](https://prettier.io/)
- Linter: [ESLint](https://eslint.org/)
Expand Down
17 changes: 16 additions & 1 deletion client/e2e/log-in-user.function.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, Page } from "@playwright/test";
import { APIRequestContext, expect, Page } from "@playwright/test";
import { CREATED } from "_server/constants/http-status-code";
import { Credentials } from "_server/types/credentials";

Expand Down Expand Up @@ -31,3 +31,18 @@ export const logInUser = async (
return response.finished();
});
};

/**
* Log in a user via API request and verify the response status.
* @param request - The API request context
* @param credentials - The user's login credentials
* @param expectedStatus - The expected response status code
*/
export const logInUserViaApi = async (
request: APIRequestContext,
credentials: Credentials,
expectedStatus = CREATED,
): Promise<void> => {
const response = await request.post("/api/session", { data: credentials });
expect(response.status()).toBe(expectedStatus);
};
2 changes: 1 addition & 1 deletion client/e2e/register-user.function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const registerUser = async (
};

/**
* Register a user via the API while verifying the response status.
* Register a user via API request and verify the response status.
* @param request - The API request context
* @param credentials - The user's credentials to register with
* @param expectedStatus - The expected response status code
Expand Down
112 changes: 112 additions & 0 deletions client/e2e/tests/password-update.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { faker } from "@faker-js/faker";
import { expect } from "@playwright/test";
import {
BAD_REQUEST,
NO_CONTENT,
UNAUTHORIZED,
} from "_server/constants/http-status-code";
import { getFakeCredentials } from "_server/test-helpers/faker-extensions";
import { getStrongLeakedPassword } from "_server/test-helpers/leaked-passwords";
import { ApiError } from "_server/types/api-error.enum";
import { UserMessage } from "../../src/app/types/user-message.enum";
import { ValidationError } from "../../src/app/types/validation-error.enum";
import { getResponsePredicate } from "../extensions";
import { test } from "../fixtures";
import { logInUserViaApi } from "../log-in-user.function";
import { registerUser } from "../register-user.function";

test("Password update", async ({ page, request }) => {
const endpoint = ["PUT", "/api/user/password"] as const;
const credentials = getFakeCredentials();
const oldPassword = credentials.password;
let newPassword: string;
const form = page.getByTestId("update-password-form");
const oldPasswordField = form.getByTestId("old-password");
const newPasswordField = form.getByTestId("new-password");
const confirmationField = form.getByTestId("confirm-new-password");
const oldPasswordInput = oldPasswordField.getByTestId("password");
const newPasswordInput = newPasswordField.getByTestId("password");
const confirmationInput = confirmationField.getByTestId("password");

const fillForm = async (
oldPassword: string,
newPassword: string,
confirmation = newPassword,
): Promise<void> => {
await oldPasswordInput.fill(oldPassword);
await newPasswordInput.fill(newPassword);
await confirmationInput.fill(confirmation);
};

await registerUser(page, credentials);
await page.goto("/.well-known/change-password");

await test.step("User cannot reuse old password", async () => {
await fillForm(oldPassword, oldPassword);
await expect(newPasswordField).toContainText(ValidationError.SAME_PASSWORD);
});

await test.step("User must confirm new password", async () => {
const newPassword = faker.internet.password();

await fillForm(oldPassword, newPassword);
await page.keyboard.press("Space");

await expect(confirmationField).toContainText(
ValidationError.PASSWORD_MISMATCH,
);
});

await test.step("User cannot use a weak password", async () => {
const newPassword = faker.internet.password({ length: 5 });

await fillForm(oldPassword, newPassword);

await expect(newPasswordField).toContainText(
ValidationError.PASSWORD_STRENGTH,
);
});

await test.step("User cannot use a leaked password", async () => {
const newPassword = getStrongLeakedPassword();
const responsePromise = page.waitForResponse(
getResponsePredicate(...endpoint),
);

await fillForm(oldPassword, newPassword);
await expect(form).toHaveClass(/ng-valid/); // Wait for validation to complete
await page.keyboard.press("Enter");

const response = await responsePromise;
expect(response.status()).toBe(BAD_REQUEST);
expect(await response.json()).toBe(ApiError.LEAKED_PASSWORD);
});

await test.step("Form submission is successful", async () => {
newPassword = faker.internet.password();
const responsePromise = page.waitForResponse(
getResponsePredicate(...endpoint),
);

await fillForm(oldPassword, newPassword);
await expect(form).toHaveClass(/ng-valid/); // Wait for validation to complete
await page.keyboard.press("Enter");

const response = await responsePromise;
expect(response.status()).toBe(NO_CONTENT);
});

await test.step("Success message is displayed", async () => {
await expect(
page.getByText(UserMessage.PASSWORD_UPDATE_SUCCESS),
).toBeVisible();
});

await test.step("User cannot log in with the old password", async () => {
await logInUserViaApi(request, credentials, UNAUTHORIZED);
});

await test.step("User can log in with the new password", async () => {
await logInUserViaApi(request, { ...credentials, password: newPassword });
});
});
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,6 @@
"test": "ng test",
"wait:servers": "wait-on tcp:3000 tcp:4200 tcp:6379 tcp:27017"
},
"type": "module",
"version": "0.0.0"
}
50 changes: 37 additions & 13 deletions client/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,34 @@ export const routes: Routes = [
title: APP_NAME,
},
{
path: "user/account",
loadComponent: async () =>
(await import("./pages/account-page/account-page.component"))
.AccountPageComponent,
path: "user",
canActivate: [isAuthenticatedGuard],
title: "Account",
},
{
path: "user/sessions",
loadComponent: async () =>
(await import("./pages/sessions-page/sessions-page.component"))
.SessionsPageComponent,
canActivate: [isAuthenticatedGuard],
title: "Session Management",
children: [
{
path: "account",
loadComponent: async () =>
(await import("./pages/account-page/account-page.component"))
.AccountPageComponent,
title: "Account",
},
{
path: "sessions",
loadComponent: async () =>
(await import("./pages/sessions-page/sessions-page.component"))
.SessionsPageComponent,
title: "Session Management",
},
{
path: "change-password",
loadComponent: async () =>
(
await import(
"./pages/update-password-page/update-password-page.component"
)
).UpdatePasswordPageComponent,
title: "Update Password",
},
],
},
{
path: "sign-in",
Expand All @@ -48,6 +62,16 @@ export const routes: Routes = [
component: NotFoundPageComponent,
title: "Not Found",
},
// Redirects for well-known URIs (see https://datatracker.ietf.org/doc/html/rfc8615)
{
path: ".well-known",
children: [
{
path: "change-password",
redirectTo: "/user/change-password",
},
],
},
{
path: "**",
redirectTo: "not-found",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<mat-form-field>
<mat-label>Password</mat-label>
<mat-label>{{ label() }}</mat-label>
<input
[attr.maxlength]="PASSWORD_MAX_LENGTH"
[autocomplete]="autocomplete()"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { PasswordStrengthService } from "../../services/password-strength.servic
export class PasswordFieldComponent implements OnInit {
readonly PASSWORD_MAX_LENGTH = PASSWORD_MAX_LENGTH;
autocomplete = input.required<"new-password" | "current-password">();
label = input("Password");
isPasswordVisible = signal(false);
passwordControl = input.required<FormControl>();
visibilityTooltip = computed(
Expand All @@ -59,11 +60,13 @@ export class PasswordFieldComponent implements OnInit {
* @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);
});
if (this.label() === "Password" || this.label() === "New password") {
this.passwordControl()
.valueChanges.pipe(takeUntilDestroyed(this.#destroyRef))
.subscribe((value) => {
if (value === "") this.#strength.result$.next(zxcvbnDefaultResult);
});
}
}

togglePasswordVisibility(event: MouseEvent): void {
Expand Down
4 changes: 3 additions & 1 deletion client/src/app/interceptors/error.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return of(true).pipe(delay(computeDelay(retryCount)));

case BAD_REQUEST:
console.error("Input validation mismatch", response);
if (response.error !== ApiError.VALIDATION_MISMATCH)
return throwError(() => response);
console.error(response);
notifier.send(response.error);
break;

Expand Down
7 changes: 2 additions & 5 deletions client/src/app/pages/account-page/account-page.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,8 @@ <h1 class="mat-headline-large">Account</h1>
<p>Username: {{ username() }}</p>
}
<div class="buttons">
<a
class="sessions-link"
data-testid="sessions-link"
mat-stroked-button
routerLink="/user/sessions"
<a mat-stroked-button routerLink="/user/change-password">Change password</a>
<a data-testid="sessions-link" mat-stroked-button routerLink="/user/sessions"
>Session management</a
>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,8 @@
display: block;
}

.sessions-link {
margin-top: 20px;
}

.buttons {
margin-top: 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<mat-card class="form-container">
<mat-card-content>
<h1 class="mat-title-medium">Change your account password</h1>
<form
(ngSubmit)="onSubmit()"
[formGroup]="form"
data-testid="update-password-form"
>
<!-- Hidden username field for a11y and autofill -->
<input autocomplete="username" hidden name="username" type="text" />
<app-password-field
[passwordControl]="form.controls.oldPassword"
autocomplete="current-password"
data-testid="old-password"
label="Old password"
/>
<app-password-field
[passwordControl]="form.controls.newPassword"
autocomplete="new-password"
data-testid="new-password"
label="New password"
/>
<app-password-field
[passwordControl]="form.controls.confirmNewPassword"
autocomplete="new-password"
data-testid="confirm-new-password"
label="Confirm new password"
/>
<app-password-strength-meter />
<button mat-flat-button type="submit">
@if (isLoading()) {
<mat-icon svgIcon="progress_activity" />
}
Change password
</button>
</form>
</mat-card-content>
</mat-card>
Loading

0 comments on commit cc660d8

Please sign in to comment.