From 6bfb1a8b833bb89af02a3e42facdf8d2b737e557 Mon Sep 17 00:00:00 2001 From: Joonatan Kuosa Date: Thu, 14 Nov 2024 09:49:18 +0200 Subject: [PATCH] add: middleware to check users ownership 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. --- apps/ui/middleware.ts | 182 ++++++++++++++++-- apps/ui/pages/applications/index.tsx | 1 + apps/ui/pages/reservations/[id]/cancel.tsx | 18 -- .../pages/reservations/[id]/confirmation.tsx | 10 +- apps/ui/pages/reservations/[id]/edit.tsx | 14 +- apps/ui/pages/reservations/[id]/index.tsx | 22 +-- 6 files changed, 167 insertions(+), 80 deletions(-) diff --git a/apps/ui/middleware.ts b/apps/ui/middleware.ts index 473f1aafd7..79e37f7035 100644 --- a/apps/ui/middleware.ts +++ b/apps/ui/middleware.ts @@ -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; @@ -24,7 +24,7 @@ type gqlQuery = { /// @param query - Query object with query and variables /// @returns Promise /// 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 @@ -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 - user id or null if not logged in -async function getCurrentUser(req: NextRequest): Promise { +async function getCurrentUser( + req: NextRequest, + opts?: { + applicationPk?: number | null; + reservationPk?: number | null; + } +): Promise { const { cookies } = req; const hasSession = cookies.has("sessionid"); if (!hasSession) { @@ -85,14 +115,37 @@ async function getCurrentUser(req: NextRequest): Promise { 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); @@ -109,22 +162,69 @@ async function getCurrentUser(req: NextRequest): Promise { 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; } @@ -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 { const { cookies } = req; const url = new URL(req.url); @@ -167,7 +267,7 @@ async function maybeSaveUserLanguage( return; } - const query: gqlQuery = { + const query: QqlQuery = { query: ` mutation SaveUserLanguage($preferredLanguage: PreferredLanguage!) { updateCurrentUser( @@ -198,7 +298,7 @@ async function maybeSaveUserLanguage( /// @returns Promise - 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; @@ -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 @@ -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); diff --git a/apps/ui/pages/applications/index.tsx b/apps/ui/pages/applications/index.tsx index 645823d00c..05995c8671 100644 --- a/apps/ui/pages/applications/index.tsx +++ b/apps/ui/pages/applications/index.tsx @@ -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({ query: CurrentUserDocument, }); diff --git a/apps/ui/pages/reservations/[id]/cancel.tsx b/apps/ui/pages/reservations/[id]/cancel.tsx index 81d5ba3d85..b868d30a72 100644 --- a/apps/ui/pages/reservations/[id]/cancel.tsx +++ b/apps/ui/pages/reservations/[id]/cancel.tsx @@ -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, @@ -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; @@ -47,12 +45,6 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) { }); const { reservation } = reservationData || {}; - const { data: userData } = await client.query({ - query: CURRENT_USER, - fetchPolicy: "no-cache", - }); - const user = userData?.currentUser; - const { data: cancelReasonsData } = await client.query< ReservationCancelReasonsQuery, ReservationCancelReasonsQueryVariables @@ -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) { diff --git a/apps/ui/pages/reservations/[id]/confirmation.tsx b/apps/ui/pages/reservations/[id]/confirmation.tsx index a5ddf63b67..696b7e305e 100644 --- a/apps/ui/pages/reservations/[id]/confirmation.tsx +++ b/apps/ui/pages/reservations/[id]/confirmation.tsx @@ -1,6 +1,5 @@ import React from "react"; import { - CurrentUserQuery, ReservationDocument, type ReservationQuery, type ReservationQueryVariables, @@ -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 @@ -118,13 +116,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { }); const { reservation } = reservationData || {}; - const { data: userData } = await client.query({ - query: CURRENT_USER, - fetchPolicy: "no-cache", - }); - const user = userData?.currentUser; - - if (user != null && user.pk === reservation?.user?.pk) { + if (reservation) { return { props: { ...getCommonServerSideProps(), diff --git a/apps/ui/pages/reservations/[id]/edit.tsx b/apps/ui/pages/reservations/[id]/edit.tsx index 97c5830c41..8091b1fdc5 100644 --- a/apps/ui/pages/reservations/[id]/edit.tsx +++ b/apps/ui/pages/reservations/[id]/edit.tsx @@ -12,7 +12,6 @@ import { ReservationDocument, type ReservationQuery, type ReservationQueryVariables, - type CurrentUserQuery, type Mutation, type MutationAdjustReservationTimeArgs, useAdjustReservationTimeMutation, @@ -21,7 +20,6 @@ import { base64encode, filterNonNullable } from "common/src/helpers"; import { toApiDate } from "common/src/common/util"; import { addYears } from "date-fns"; import { RELATED_RESERVATION_STATES } from "common/src/const"; -import { CURRENT_USER } from "@/modules/queries/user"; import { type FetchResult } from "@apollo/client"; import { useRouter } from "next/router"; import { StepState, Stepper } from "hds-react"; @@ -289,12 +287,6 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) { }); const { reservationUnit } = reservationUnitData; - const { data: userData } = await client.query({ - query: CURRENT_USER, - fetchPolicy: "no-cache", - }); - const user = userData?.currentUser; - const options = await queryOptions(client, locale ?? ""); const timespans = filterNonNullable(reservationUnit?.reservableTimeSpans); @@ -302,11 +294,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) { reservationUnitData?.affectingReservations ); - if ( - reservation != null && - reservationUnit != null && - reservation.user?.pk === user?.pk - ) { + if (reservation != null && reservationUnit != null) { return { props: { ...commonProps, diff --git a/apps/ui/pages/reservations/[id]/index.tsx b/apps/ui/pages/reservations/[id]/index.tsx index 47cdd4eb8c..135e664d1f 100644 --- a/apps/ui/pages/reservations/[id]/index.tsx +++ b/apps/ui/pages/reservations/[id]/index.tsx @@ -21,8 +21,6 @@ import { ReservationDocument, type ReservationQuery, type ReservationQueryVariables, - CurrentUserDocument, - type CurrentUserQuery, OrderStatus, } from "@gql/gql-types"; import Link from "next/link"; @@ -647,7 +645,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) { // NOTE errors will fallback to 404 const id = base64encode(`ReservationNode:${pk}`); - const { data, error } = await apolloClient.query< + const { data } = await apolloClient.query< ReservationQuery, ReservationQueryVariables >({ @@ -656,24 +654,8 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) { variables: { id }, }); - const { data: userData } = await apolloClient.query({ - query: CurrentUserDocument, - fetchPolicy: "no-cache", - }); - const user = userData?.currentUser; - - if (error) { - // eslint-disable-next-line no-console - console.error("Error while fetching reservation", error); - } - const { reservation } = data ?? {}; - // Return 404 for unauthorized access - if ( - reservation != null && - user != null && - reservation.user?.pk === user.pk - ) { + if (reservation != null) { return { props: { ...commonProps,