Skip to content

Commit

Permalink
fix: add middleware to check users ownership
Browse files Browse the repository at this point in the history
Customer application user should have access to reservations and
applications only for their own.

For unauthorized access return 404 error.
  • Loading branch information
joonatank committed Nov 15, 2024
1 parent b27d7bf commit 5d3b36d
Show file tree
Hide file tree
Showing 7 changed files with 184 additions and 80 deletions.
4 changes: 4 additions & 0 deletions apps/ui/gql/gql-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8132,6 +8132,7 @@ export type ApplicationViewQuery = {
pk?: number | null;
} | null;
} | null;
currentUser?: { pk?: number | null } | null;
};

export const PricingFieldsFragmentDoc = gql`
Expand Down Expand Up @@ -11478,6 +11479,9 @@ export const ApplicationViewDocument = gql`
}
}
}
currentUser {
pk
}
}
${ApplicationCommonFragmentDoc}
${TermsOfUseFieldsFragmentDoc}
Expand Down
195 changes: 175 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,55 @@ export async function middleware(req: NextRequest) {
return NextResponse.next();
}

const user = await getCurrentUser(req);
// 1st
// if the user is logged in but doesn't own the reservation / application -> 404
// if the user is not logged in but it's authentcated route -> redirect
// otherwise continue
// 2nd
// if the user is logged in maybeSaveUserLanguage
// otherwise continue
//
// ordering it like this has a slight problem
// we are not saving the user language on redirects / 404 (i.e. errors)
// - dont think it matters though, since they should not end up there anyway
// - we can swap it around?

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 5d3b36d

Please sign in to comment.