Skip to content
This repository has been archived by the owner on Jan 4, 2025. It is now read-only.

Commit

Permalink
Merge pull request #98 from johnnygerard/ajv
Browse files Browse the repository at this point in the history
feat: add JSON parsing and validation with ajv
  • Loading branch information
johnnygerard authored Nov 3, 2024
2 parents e8b4e57 + 19a0321 commit f218be3
Show file tree
Hide file tree
Showing 28 changed files with 307 additions and 134 deletions.
6 changes: 2 additions & 4 deletions client/e2e/log-in-user.function.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, Page } from "@playwright/test";
import { CREATED } from "_server/constants/http-status-code";
import { Credentials } from "_server/types/credentials";

/**
* Fill out and submit the login form while verifying the response status.
Expand All @@ -9,10 +10,7 @@ import { CREATED } from "_server/constants/http-status-code";
*/
export const logInUser = async (
page: Page,
credentials: {
username: string;
password: string;
},
credentials: Credentials,
expectedStatus = CREATED,
): Promise<void> => {
await page.goto("/sign-in");
Expand Down
6 changes: 2 additions & 4 deletions client/e2e/register-user.function.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { faker } from "@faker-js/faker";
import { expect, Page } from "@playwright/test";
import { CREATED } from "_server/constants/http-status-code";
import { Credentials } from "_server/types/credentials";

/**
* Fill out and submit the registration form while verifying the response status.
Expand All @@ -10,10 +11,7 @@ import { CREATED } from "_server/constants/http-status-code";
*/
export const registerUser = async (
page: Page,
credentials?: Partial<{
username: string;
password: string;
}>,
credentials?: Partial<Credentials>,
expectedStatus = CREATED,
): Promise<void> => {
await page.goto("/register");
Expand Down
3 changes: 2 additions & 1 deletion client/e2e/tests/session-revocation.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { expect, test } from "@playwright/test";
import { getFakeCredentials } from "_server/test-helpers/faker-extensions";
import { Credentials } from "_server/types/credentials";
import { UserMessage } from "../../src/app/types/user-message.enum";
import { logInUser } from "../log-in-user.function";
import { registerUser } from "../register-user.function";

test.describe("Session revocation", () => {
let credentials: { username: string; password: string };
let credentials: Credentials;

test.beforeEach(async ({ browser, page }) => {
const context = await browser.newContext();
Expand Down
2 changes: 1 addition & 1 deletion client/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import ms from "ms";
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
timeout: ms("10 seconds"),
timeout: ms("20 seconds"),
globalTimeout: ms("5 minutes"),
testDir: "./e2e",
/* Run tests in files in parallel */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export class RegisterFormComponent {
}

onSubmit(): void {
if (!this.form.valid || this.isLoading()) return;
if (this.form.invalid || this.isLoading()) return;
if (this.#passwordStrength.isWorkerBusy()) {
this.#shouldResubmit = true;
return;
Expand Down
8 changes: 2 additions & 6 deletions client/src/app/directives/username-validator.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@ import {
ValidationErrors,
Validator,
} from "@angular/forms";
import {
usernameHasValidCharacters,
usernameHasValidType,
} from "_server/validation/username";
import { hasValidUsernameCharacters } from "_server/validation/username";

@Directive({
selector: "[appUsernameValidator]",
Expand All @@ -25,8 +22,7 @@ export class UsernameValidatorDirective implements Validator {
validate(control: AbstractControl): ValidationErrors | null {
const username = control.value;

return usernameHasValidType(username) &&
!usernameHasValidCharacters(username)
return typeof username === "string" && !hasValidUsernameCharacters(username)
? { pattern: "Invalid characters" }
: null;
}
Expand Down
44 changes: 44 additions & 0 deletions server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"dependencies": {
"ajv": "^8.17.1",
"argon2": "^0.41.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
Expand Down
19 changes: 19 additions & 0 deletions server/src/auth/is-leaked-password.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { faker } from "@faker-js/faker";
import assert from "node:assert/strict";
import { suite, test } from "node:test";
import { getLeakedPassword } from "../test-helpers/leaked-passwords.js";
import { isLeakedPassword } from "./is-leaked-password.js";

suite("The isLeakedPassword function", () => {
test("returns true for a leaked password", async () => {
const password = getLeakedPassword();
const isLeaked = await isLeakedPassword(password);
assert(isLeaked, `Password "${password}" is not leaked`);
});

test("returns false for a non-leaked password", async () => {
const password = faker.internet.password();
const isLeaked = await isLeakedPassword(password);
assert(!isLeaked, `Password "${password}" is leaked`);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import { OK } from "../constants/http-status-code.js";
* This function queries the Pwned Passwords API using the k-Anonymity model
* (only a partial digest of the hashed password is sent).
* @param password - Plaintext password
* @returns `false` if the password is not exposed or the API server did not
* reply in time with the 200 status code, `true` if the password is exposed.
* @returns `false` if the password is not leaked or the API server did not
* reply in time with the 200 status code, `true` if the password is leaked.
* @see https://haveibeenpwned.com/API/v3#PwnedPasswords
*/
export const isPasswordExposed = async (password: string): Promise<boolean> => {
export const isLeakedPassword = async (password: string): Promise<boolean> => {
try {
const digest = hash("sha1", password);
const partialDigest = digest.slice(0, 5);
Expand Down
19 changes: 0 additions & 19 deletions server/src/auth/pwned-passwords-api.test.ts

This file was deleted.

35 changes: 19 additions & 16 deletions server/src/controllers/create-account.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { RequestHandler } from "express";
import { generateCSRFToken } from "../auth/csrf.js";
import { isLeakedPassword } from "../auth/is-leaked-password.js";
import { hashPassword } from "../auth/password-hashing.js";
import { isPasswordExposed } from "../auth/pwned-passwords-api.js";
import {
BAD_REQUEST,
CONFLICT,
Expand All @@ -11,12 +11,11 @@ import { users } from "../database/mongo-client.js";
import { User } from "../models/user.js";
import { sessionStore } from "../session/redis-session-store.js";
import { generateSessionCookie } from "../session/session-cookie.js";
import { ApiError } from "../types/api-error.enum.js";
import { ServerSession } from "../types/server-session.js";
import { isPasswordStrong } from "../validation/password.js";
import {
usernameHasValidType,
usernameHasValidValue,
} from "../validation/username.js";
import { parseCredentials } from "../validation/ajv/credentials.js";
import { isValidPassword } from "../validation/password.js";
import { isValidUsername } from "../validation/username.js";

const isUsernameTaken = async (username: string): Promise<boolean> => {
const user = await users.findOne({ username }, { projection: { _id: 1 } });
Expand All @@ -25,10 +24,17 @@ const isUsernameTaken = async (username: string): Promise<boolean> => {

export const createAccount: RequestHandler = async (req, res, next) => {
try {
const { username, password } = req.body;
const credentials = parseCredentials(req.body);

if (!credentials) {
res.status(BAD_REQUEST).json(ApiError.VALIDATION_MISMATCH);
return;
}

const { username, password } = credentials;

if (!usernameHasValidType(username) || !usernameHasValidValue(username)) {
res.status(BAD_REQUEST).json("Invalid username");
if (!isValidUsername(username)) {
res.status(BAD_REQUEST).json(ApiError.VALIDATION_MISMATCH);
return;
}

Expand All @@ -38,16 +44,13 @@ export const createAccount: RequestHandler = async (req, res, next) => {
return;
}

if (
typeof password !== "string" ||
!(await isPasswordStrong(password, username))
) {
res.status(BAD_REQUEST).json("Invalid password");
if (!(await isValidPassword(password, username))) {
res.status(BAD_REQUEST).json(ApiError.VALIDATION_MISMATCH);
return;
}

if (await isPasswordExposed(password)) {
res.status(BAD_REQUEST).json("Your password was leaked in a data breach");
if (await isLeakedPassword(password)) {
res.status(BAD_REQUEST).json(ApiError.LEAKED_PASSWORD);
return;
}

Expand Down
17 changes: 5 additions & 12 deletions server/src/controllers/create-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,23 @@ import {
CREATED,
UNAUTHORIZED,
} from "../constants/http-status-code.js";
import { PASSWORD_MAX_LENGTH } from "../constants/password.js";
import { users } from "../database/mongo-client.js";
import { sessionStore } from "../session/redis-session-store.js";
import { generateSessionCookie } from "../session/session-cookie.js";
import { ApiError } from "../types/api-error.enum.js";
import { ServerSession } from "../types/server-session.js";
import {
usernameHasValidType,
usernameHasValidValue,
} from "../validation/username.js";
import { parseCredentials } from "../validation/ajv/credentials.js";

export const createSession: RequestHandler = async (req, res, next) => {
try {
const { username, password } = req.body;
const credentials = parseCredentials(req.body);

if (!usernameHasValidType(username) || !usernameHasValidValue(username)) {
res.status(BAD_REQUEST).json("Invalid username");
if (!credentials) {
res.status(BAD_REQUEST).json(ApiError.VALIDATION_MISMATCH);
return;
}

if (typeof password !== "string" || password.length > PASSWORD_MAX_LENGTH) {
res.status(BAD_REQUEST).json("Invalid password");
return;
}
const { username, password } = credentials;

// Retrieve user from database
const user = await users.findOne(
Expand Down
9 changes: 5 additions & 4 deletions server/src/controllers/delete-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,22 @@ import {
NO_CONTENT,
UNAUTHORIZED,
} from "../constants/http-status-code.js";
import { PASSWORD_MAX_LENGTH } from "../constants/password.js";
import { users } from "../database/mongo-client.js";
import { ApiError } from "../types/api-error.enum.js";
import { parsePassword } from "../validation/ajv/password.js";

export const deleteAccount: RequestHandler = async (req, res, next) => {
try {
const userId = req.user!.id;
const _id = new ObjectId(userId);
const { password } = req.body;
const credentials = parsePassword(req.body);

if (typeof password !== "string" || password.length > PASSWORD_MAX_LENGTH) {
res.status(BAD_REQUEST).json("Invalid password");
if (!credentials) {
res.status(BAD_REQUEST).json(ApiError.VALIDATION_MISMATCH);
return;
}

const { password } = credentials;
const user = await users.findOne({ _id }, { projection: { password: 1 } });

if (!user) {
Expand Down
Loading

0 comments on commit f218be3

Please sign in to comment.