Skip to content

Commit

Permalink
add: middleware to check users ownership
Browse files Browse the repository at this point in the history
In customer app user should have access to reservations and
applications only if they own them (even if they have higher access in
admin application).

For unauthorized access return 404 error.
  • Loading branch information
joonatank committed Nov 18, 2024
1 parent 3b7e1c4 commit 6bfb1a8
Show file tree
Hide file tree
Showing 6 changed files with 167 additions and 80 deletions.
182 changes: 162 additions & 20 deletions apps/ui/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { env } from "@/env.mjs";
import { getSignInUrl, buildGraphQLUrl } from "common/src/urlBuilder";
import { type LocalizationLanguages } from "common/src/helpers";
import { base64encode, type LocalizationLanguages } from "common/src/helpers";

const apiBaseUrl = env.TILAVARAUS_API_URL ?? "";

type gqlQuery = {
type QqlQuery = {
query: string;
// TODO don't type to unknown (undefined and Date break JSON.stringify)
variables: Record<string, unknown>;
Expand All @@ -24,7 +24,7 @@ type gqlQuery = {
/// @param query - Query object with query and variables
/// @returns Promise<Response>
/// custom function so we don't have to import apollo client in middleware
async function gqlQueryFetch(req: NextRequest, query: gqlQuery) {
async function gqlQueryFetch(req: NextRequest, query: QqlQuery) {
const { cookies, headers } = req;
// TODO this is copy to the createApolloClient function but different header types
// NextRequest vs. RequestInit
Expand Down Expand Up @@ -68,10 +68,40 @@ async function gqlQueryFetch(req: NextRequest, query: gqlQuery) {
});
}

type User = {
pk: number;
hasAccess: boolean;
};

const RESERVATION_QUERY = `
reservation(id: $reservationId) {
id
user {
id
pk
}
}`;

const APPLICATION_QUERY = `
application(id: $applicationId) {
id
user {
id
pk
}
}`;

/// Get the current user from the backend
/// @param req - NextRequest
/// @param opts - optional parameters for fetching additional data
/// @returns Promise<number | null> - user id or null if not logged in
async function getCurrentUser(req: NextRequest): Promise<number | null> {
async function getCurrentUser(
req: NextRequest,
opts?: {
applicationPk?: number | null;
reservationPk?: number | null;
}
): Promise<User | null> {
const { cookies } = req;
const hasSession = cookies.has("sessionid");
if (!hasSession) {
Expand All @@ -85,14 +115,37 @@ async function getCurrentUser(req: NextRequest): Promise<number | null> {
return null;
}

const query: gqlQuery = {
const applicationId =
opts?.applicationPk != null
? base64encode(`ApplicationNode:${opts.applicationPk}`)
: null;
const reservationId =
opts?.reservationPk != null
? base64encode(`ReservationNode:${opts.reservationPk}`)
: null;

// NOTE: need to build queries dynamically because of the optional parameters
const params =
reservationId != null || applicationId != null
? `(
${reservationId ? "$reservationId: ID!" : ""}
${applicationId ? "$applicationId: ID!" : ""}
)`
: "";

const query: QqlQuery = {
query: `
query GetCurrentUser {
query GetCurrentUser ${params} {
currentUser {
pk
}
${reservationId ? RESERVATION_QUERY : ""}
${applicationId ? APPLICATION_QUERY : ""}
}`,
variables: {},
variables: {
...(reservationId != null ? { reservationId } : {}),
...(applicationId != null ? { applicationId } : {}),
},
};
const res = await gqlQueryFetch(req, query);

Expand All @@ -109,22 +162,69 @@ async function getCurrentUser(req: NextRequest): Promise<number | null> {
console.warn("no data in response");
return null;
}
if (
typeof data.data === "object" &&
data.data != null &&
"currentUser" in data.data
) {
const { currentUser } = data.data;

return parseUserGQLquery(data.data, reservationId, applicationId);
}

function parseUserGQLquery(
data: unknown,
reservationId: string | null,
applicationId: string | null
): User | null {
let userPk = null;
let hasAccess = reservationId == null && applicationId == null;
if (typeof data !== "object" || data == null) {
return null;
}

if ("currentUser" in data) {
const { currentUser } = data;
if (
typeof currentUser === "object" &&
currentUser != null &&
"pk" in currentUser
) {
if (typeof currentUser.pk === "number") {
return currentUser.pk;
userPk = typeof currentUser.pk === "number" ? currentUser.pk : null;
}
}

if ("reservation" in data) {
const { reservation } = data;
if (
reservation != null &&
typeof reservation === "object" &&
"user" in reservation &&
reservation.user != null &&
typeof reservation.user === "object" &&
"pk" in reservation.user
) {
const { pk } = reservation.user;
if (pk != null && typeof pk === "number") {
hasAccess = pk === userPk;
}
}
}

if ("application" in data) {
const { application } = data;
if (
application != null &&
typeof application === "object" &&
"user" in application &&
application.user != null &&
typeof application.user === "object" &&
"pk" in application.user
) {
const { pk } = application.user;
if (pk != null && typeof pk === "number") {
hasAccess = pk === userPk;
}
}
}

if (userPk != null) {
return { pk: userPk, hasAccess };
}
return null;
}

Expand All @@ -149,7 +249,7 @@ function getLocalizationFromUrl(url: URL): LocalizationLanguages {
/// NOTE The responsibility to update the cookie is on the caller (who creates the next request).
async function maybeSaveUserLanguage(
req: NextRequest,
user: number | null
user: User | null
): Promise<string | undefined> {
const { cookies } = req;
const url = new URL(req.url);
Expand All @@ -167,7 +267,7 @@ async function maybeSaveUserLanguage(
return;
}

const query: gqlQuery = {
const query: QqlQuery = {
query: `
mutation SaveUserLanguage($preferredLanguage: PreferredLanguage!) {
updateCurrentUser(
Expand Down Expand Up @@ -198,7 +298,7 @@ async function maybeSaveUserLanguage(
/// @returns Promise<string | undefined> - the redirect url or null if no redirect is needed
function getRedirectProtectedRoute(
req: NextRequest,
user: number | null
user: User | null
): string | null {
const { headers } = req;

Expand Down Expand Up @@ -267,11 +367,18 @@ function redirectCsrfToken(req: NextRequest): URL | undefined {
// refactor the matcher or fix the underlining matcher issue in nextjs
// matcher syntax: /hard-path/:path* -> /hard-path/anything
// our syntax: hard-path
const authenticatedRoutes = [
const reservationRoutes = [
"reservation", //:path*',
"reservations", //:path*',
];
const applicationRoutes = [
"applications", //:path*',
"application", //:path*',
];
const authenticatedRoutes = [
// just in case if the route falls through
...reservationRoutes,
...applicationRoutes,
"success",
];
// url matcher that is very specific to our case
Expand All @@ -297,7 +404,42 @@ export async function middleware(req: NextRequest) {
return NextResponse.next();
}

const user = await getCurrentUser(req);
const url = new URL(req.url);
let reservationPk: number | null = null;
let applicationPk: number | null = null;

if (reservationRoutes.some((route) => doesUrlMatch(req.url, route))) {
const id = url.pathname.match(/\/reservations?\/(\d+)/)?.[1];
const pk = Number(id);
// can be either an url issues (user error) or a bug in our matcher
if (pk > 0) {
reservationPk = pk;
} else {
// eslint-disable-next-line no-console
console.error("Invalid reservation id");
}
}
if (applicationRoutes.some((route) => doesUrlMatch(req.url, route))) {
const id = url.pathname.match(/\/applications?\/(\d+)/)?.[1];
const pk = Number(id);
// can be either an url issues (user error) or a bug in our matcher
if (pk > 0) {
applicationPk = pk;
} else {
// eslint-disable-next-line no-console
console.error("Invalid application id");
}
}

const options = {
applicationPk,
reservationPk,
};

const user = await getCurrentUser(req, options);
if (user != null && !user.hasAccess) {
return NextResponse.error();
}

if (authenticatedRoutes.some((route) => doesUrlMatch(req.url, route))) {
const redirect = getRedirectProtectedRoute(req, user);
Expand Down
1 change: 1 addition & 0 deletions apps/ui/pages/applications/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const commonProps = getCommonServerSideProps();
const client = createApolloClient(commonProps.apiBaseUrl, ctx);

// NOTE have to be done with double query because applications returns everything the user has access to (not what he owns)
const { data: userData } = await client.query<CurrentUserQuery>({
query: CurrentUserDocument,
});
Expand Down
18 changes: 0 additions & 18 deletions apps/ui/pages/reservations/[id]/cancel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import React from "react";
import type { GetServerSidePropsContext } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import {
CurrentUserQuery,
ReservationCancelReasonsDocument,
type ReservationCancelReasonsQuery,
type ReservationCancelReasonsQueryVariables,
Expand All @@ -15,7 +14,6 @@ import { getCommonServerSideProps } from "@/modules/serverUtils";
import { createApolloClient } from "@/modules/apolloClient";
import { base64encode, filterNonNullable } from "common/src/helpers";
import { isReservationCancellable } from "@/modules/reservation";
import { CURRENT_USER } from "@/modules/queries/user";
import { getReservationPath } from "@/modules/urls";

type PropsNarrowed = Exclude<Props, { notFound: boolean }>;
Expand Down Expand Up @@ -47,12 +45,6 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
});
const { reservation } = reservationData || {};

const { data: userData } = await client.query<CurrentUserQuery>({
query: CURRENT_USER,
fetchPolicy: "no-cache",
});
const user = userData?.currentUser;

const { data: cancelReasonsData } = await client.query<
ReservationCancelReasonsQuery,
ReservationCancelReasonsQueryVariables
Expand All @@ -67,16 +59,6 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
)
);

if (reservation?.user?.pk !== user?.pk) {
return {
notFound: true,
props: {
notFound: true,
...commonProps,
...(await serverSideTranslations(locale ?? "fi")),
},
};
}
const canCancel =
reservation != null && isReservationCancellable(reservation);
if (canCancel) {
Expand Down
10 changes: 1 addition & 9 deletions apps/ui/pages/reservations/[id]/confirmation.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React from "react";
import {
CurrentUserQuery,
ReservationDocument,
type ReservationQuery,
type ReservationQueryVariables,
Expand All @@ -18,7 +17,6 @@ import { getCommonServerSideProps } from "@/modules/serverUtils";
import { base64encode } from "common/src/helpers";
import { CenterSpinner } from "@/components/common/common";
import Error from "next/error";
import { CURRENT_USER } from "@/modules/queries/user";
import { createApolloClient } from "@/modules/apolloClient";

// TODO styles are copies from [...params].tsx
Expand Down Expand Up @@ -118,13 +116,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
});
const { reservation } = reservationData || {};

const { data: userData } = await client.query<CurrentUserQuery>({
query: CURRENT_USER,
fetchPolicy: "no-cache",
});
const user = userData?.currentUser;

if (user != null && user.pk === reservation?.user?.pk) {
if (reservation) {
return {
props: {
...getCommonServerSideProps(),
Expand Down
Loading

0 comments on commit 6bfb1a8

Please sign in to comment.