From 82487a1dc8ab44b0a345c76459f42d631cdf648f Mon Sep 17 00:00:00 2001 From: Hafiz Mohsin Ayoob <41894237+mohsinayoob@users.noreply.github.com> Date: Fri, 1 Nov 2024 04:16:12 +0500 Subject: [PATCH] Feat: Request signature generation to interact with API gateway (#2226) * introduced proxy to backend with redirect to auth * feat: introduced signature generation for interecting with api gateway * fix: added documentation and resolved some changes requested * adds documentation * fix: added comment to explain the purpose of [...path] file * Fix linting issues * fix: added support for content and auth service through API gateway * fix: resolve build issue for static props building * fix: fixed env var name --------- Co-authored-by: Osama Sayed --- .env.example | 5 +- package.json | 2 + src/api.ts | 78 ++++++++++++++++++++++++++++---- src/pages/api/proxy/[...path].ts | 13 +++++- src/pages/media/index.tsx | 35 ++++++++++---- src/pages/search.tsx | 55 ++++++++++++---------- src/utils/api.ts | 17 +++++-- src/utils/apiPaths.ts | 24 ++++++---- src/utils/auth/signature.ts | 61 +++++++++++++++++++++++++ src/utils/url.ts | 2 +- yarn.lock | 10 ++++ 11 files changed, 245 insertions(+), 57 deletions(-) create mode 100644 src/utils/auth/signature.ts diff --git a/.env.example b/.env.example index b07b28e471..05de88231a 100644 --- a/.env.example +++ b/.env.example @@ -14,4 +14,7 @@ NEXT_PUBLIC_NOVU_APP_ID= NEXT_PUBLIC_SENTRY_DSN= NEXT_PUBLIC_SERVER_SENTRY_ENABLED=false NEXT_PUBLIC_CLIENT_SENTRY_ENABLED=true -NODE_TLS_REJECT_UNAUTHORIZED=0 #set this only when SSL is self signed \ No newline at end of file + +NODE_TLS_REJECT_UNAUTHORIZED=0 #set this only when SSL is self signed +SIGNATURE_TOKEN=1234 +INTERNAL_CLIENT_ID=QDC_WEB \ No newline at end of file diff --git a/package.json b/package.json index e9187f24a0..de66b9fad4 100644 --- a/package.json +++ b/package.json @@ -74,11 +74,13 @@ "@sanity/client": "^5.2.1", "@sanity/image-url": "^1.0.2", "@sentry/nextjs": "^7.77.0", + "@types/crypto-js": "^4.2.2", "@umalqura/core": "^0.0.7", "@xstate/react": "^3.0.1", "classnames": "^2.3.2", "clipboard-copy": "^4.0.1", "cookie": "^0.5.0", + "crypto-js": "^4.2.0", "firebase": "^9.10.0", "fuse.js": "^6.6.2", "groq": "^3.4.0", diff --git a/src/api.ts b/src/api.ts index 3f268a0cdc..b77b1bd07d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines */ import { camelizeKeys } from 'humps'; +import { NextApiRequest } from 'next'; import { SearchRequestParams, SearchMode } from '@/types/Search/SearchRequestParams'; import NewSearchResponse from '@/types/Search/SearchResponse'; @@ -28,6 +29,7 @@ import { makeByRangeVersesUrl, makeWordByWordTranslationsUrl, } from '@/utils/apiPaths'; +import generateSignature from '@/utils/auth/signature'; import { SearchRequest, AdvancedCopyRequest, PagesLookUpRequest } from 'types/ApiRequests'; import { TranslationsResponse, @@ -59,15 +61,41 @@ export const SEARCH_FETCH_OPTIONS = { export const OFFLINE_ERROR = 'OFFLINE'; +export const X_AUTH_SIGNATURE = 'x-auth-signature'; +export const X_TIMESTAMP = 'x-timestamp'; +export const X_INTERNAL_CLIENT = 'x-internal-client'; + export const fetcher = async function fetcher( input: RequestInfo, - init?: RequestInit, + init: RequestInit = {}, + isStaticBuild: boolean = false, ): Promise { // if the user is not online when making the API call if (typeof window !== 'undefined' && !window.navigator.onLine) { throw new Error(OFFLINE_ERROR); } - const res = await fetch(input, init); + + let reqInit = init; + if (isStaticBuild) { + const req: NextApiRequest = { + url: typeof input === 'string' ? input : input.url, + method: init.method || 'GET', + body: init.body, + headers: init.headers, + query: {}, + } as NextApiRequest; + + const { signature, timestamp } = generateSignature(req, req.url); + const headers = { + ...init.headers, + [X_AUTH_SIGNATURE]: signature, + [X_TIMESTAMP]: timestamp, + [X_INTERNAL_CLIENT]: process.env.INTERNAL_CLIENT_ID, + }; + reqInit = { ...init, headers }; + } + + const res = await fetch(input, reqInit); if (!res.ok || res.status === 500 || res.status === 404) { throw res; } @@ -75,11 +103,23 @@ export const fetcher = async function fetcher( return camelizeKeys(json); }; +/** + * Get the verses of a specific chapter. + * + * @param {string | number} id the ID of the chapter. + * @param {string} locale the locale. + * @param {Record} params optional parameters. + * @param {boolean} isStaticBuild flag indicating if the request is for a static build. + * + * @returns {Promise} + */ export const getChapterVerses = async ( id: string | number, locale: string, params?: Record, -): Promise => fetcher(makeVersesUrl(id, locale, params)); + isStaticBuild: boolean = false, +): Promise => + fetcher(makeVersesUrl(id, locale, params, isStaticBuild), {}, isStaticBuild); export const getRangeVerses = async ( locale: string, @@ -90,11 +130,15 @@ export const getRangeVerses = async ( * Get the current available translations with the name translated in the current language. * * @param {string} language we use this to get translated names of authors in specific the current language. + * @param {boolean} isStaticBuild flag indicating if the request is for a static build. * * @returns {Promise} */ -export const getAvailableTranslations = async (language: string): Promise => - fetcher(makeTranslationsUrl(language)); +export const getAvailableTranslations = async ( + language: string, + isStaticBuild: boolean = false, +): Promise => + fetcher(makeTranslationsUrl(language, isStaticBuild), {}, isStaticBuild); /** * Get the current available wbw translations with the name translated in the current language. @@ -111,23 +155,31 @@ export const getAvailableWordByWordTranslations = async ( * Get the current available languages with the name translated in the current language. * * @param {string} language we use this to get language names in specific the current language. + * @param {boolean} isStaticBuild flag indicating if the request is for a static build. * * @returns {Promise} */ -export const getAvailableLanguages = async (language: string): Promise => - fetcher(makeLanguagesUrl(language)); +export const getAvailableLanguages = async ( + language: string, + isStaticBuild: boolean = false, +): Promise => + fetcher(makeLanguagesUrl(language, isStaticBuild), {}, isStaticBuild); /** * Get list of available reciters. * * @param {string} locale the locale. + * @param {string[]} fields optional fields to include. + * @param {boolean} isStaticBuild flag indicating if the request is for a static build. * * @returns {Promise} */ export const getAvailableReciters = async ( locale: string, fields?: string[], -): Promise => fetcher(makeAvailableRecitersUrl(locale, fields)); + isStaticBuild: boolean = false, +): Promise => + fetcher(makeAvailableRecitersUrl(locale, fields, isStaticBuild), {}, isStaticBuild); export const getReciterData = async (reciterId: string, locale: string): Promise => fetcher(makeReciterUrl(reciterId, locale)); @@ -139,15 +191,21 @@ export const getReciterData = async (reciterId: string, locale: string): Promise * * @param {number} reciterId * @param {number} chapter the id of the chapter + * @param {boolean} segments flag to include segments. + * @param {boolean} isStaticBuild flag indicating if the request is for a static build. + * + * @returns {Promise} */ - export const getChapterAudioData = async ( reciterId: number, chapter: number, segments = false, + isStaticBuild: boolean = false, ): Promise => { const res = await fetcher( - makeChapterAudioDataUrl(reciterId, chapter, segments), + makeChapterAudioDataUrl(reciterId, chapter, segments, isStaticBuild), + {}, + isStaticBuild, ); if (res.error) { diff --git a/src/pages/api/proxy/[...path].ts b/src/pages/api/proxy/[...path].ts index 3d1d9c5ddb..7143fb8e02 100644 --- a/src/pages/api/proxy/[...path].ts +++ b/src/pages/api/proxy/[...path].ts @@ -3,6 +3,9 @@ import { EventEmitter } from 'events'; import { createProxyMiddleware, fixRequestBody } from 'http-proxy-middleware'; import { NextApiRequest, NextApiResponse } from 'next'; +import { X_AUTH_SIGNATURE, X_INTERNAL_CLIENT, X_TIMESTAMP } from '@/api'; +import generateSignature from '@/utils/auth/signature'; + // Define error messages in a constant object const ERROR_MESSAGES = { PROXY_ERROR: 'Proxy error', @@ -18,7 +21,7 @@ EventEmitter.defaultMaxListeners = Number(process.env.PROXY_DEFAULT_MAX_LISTENER // to the backend server, allowing for features like cookie handling and request body fixing, which are essential // for maintaining session state and ensuring correct request formatting while in a cross domain env. const apiProxy = createProxyMiddleware({ - target: process.env.NEXT_PUBLIC_AUTH_BASE_URL, + target: process.env.API_GATEWAY_URL, changeOrigin: true, pathRewrite: { '^/api/proxy': '' }, // eslint-disable-line @typescript-eslint/naming-convention secure: process.env.NEXT_PUBLIC_VERCEL_ENV === 'production', // Disable SSL verification to avoid UNABLE_TO_VERIFY_LEAF_SIGNATURE error for dev @@ -31,6 +34,14 @@ const apiProxy = createProxyMiddleware({ proxyReq.setHeader('Cookie', req.headers.cookie); } + // Generate and attach signature headers + const requestUrl = `${process.env.API_GATEWAY_URL}${req.url}`; + const { signature, timestamp } = generateSignature(req, requestUrl); + + proxyReq.setHeader(X_AUTH_SIGNATURE, signature); + proxyReq.setHeader(X_TIMESTAMP, timestamp); + proxyReq.setHeader(X_INTERNAL_CLIENT, process.env.INTERNAL_CLIENT_ID); + // Fix the request body if bodyParser is involved fixRequestBody(proxyReq, req); }, diff --git a/src/pages/media/index.tsx b/src/pages/media/index.tsx index 10a3d6f7c4..63853afab5 100644 --- a/src/pages/media/index.tsx +++ b/src/pages/media/index.tsx @@ -175,8 +175,8 @@ const MediaMaker: NextPage = ({ isValidating: isVersesValidating, error: versesError, } = useSWRImmutable( - makeVersesUrl(surah, lang, API_PARAMS), - () => getChapterVerses(surah, lang, API_PARAMS), + makeVersesUrl(surah, lang, API_PARAMS, true), + () => getChapterVerses(surah, lang, API_PARAMS, true), { fallbackData: defaultVerses, revalidateOnMount: shouldRefetchVersesData, @@ -191,8 +191,8 @@ const MediaMaker: NextPage = ({ isValidating: isAudioValidating, error: audioError, } = useSWRImmutable( - makeChapterAudioDataUrl(reciter, surah, true), - () => getChapterAudioData(reciter, surah, true), + makeChapterAudioDataUrl(reciter, surah, true, true), + () => getChapterAudioData(reciter, surah, true, true), { fallbackData: defaultAudio, // only revalidate when the reciter or chapter has changed @@ -444,14 +444,29 @@ const MediaMaker: NextPage = ({ ); }; +const fetchRecitersAndTranslations = async (locale) => { + const { reciters } = await getAvailableReciters(locale, [], true); + const { translations } = await getAvailableTranslations(locale, true); + return { reciters, translations }; +}; + +const fetchChapterData = async (locale) => { + const chaptersData = await getAllChaptersData(locale); + const englishChaptersList = await getAllChaptersData('en'); + return { chaptersData, englishChaptersList }; +}; + +const fetchVersesAndAudio = async (locale) => { + const verses = await getChapterVerses(DEFAULT_SURAH, locale, DEFAULT_API_PARAMS, true); + const chapterAudioData = await getChapterAudioData(DEFAULT_RECITER_ID, DEFAULT_SURAH, true, true); + return { verses, chapterAudioData }; +}; + export const getStaticProps: GetStaticProps = async ({ locale }) => { try { - const { reciters } = await getAvailableReciters(locale, []); - const { translations } = await getAvailableTranslations(locale); - const chaptersData = await getAllChaptersData(locale); - const englishChaptersList = await getAllChaptersData('en'); - const verses = await getChapterVerses(DEFAULT_SURAH, locale, DEFAULT_API_PARAMS); - const chapterAudioData = await getChapterAudioData(DEFAULT_RECITER_ID, DEFAULT_SURAH, true); + const { reciters, translations } = await fetchRecitersAndTranslations(locale); + const { chaptersData, englishChaptersList } = await fetchChapterData(locale); + const { verses, chapterAudioData } = await fetchVersesAndAudio(locale); return { props: { diff --git a/src/pages/search.tsx b/src/pages/search.tsx index 868f9ca522..94708bc307 100644 --- a/src/pages/search.tsx +++ b/src/pages/search.tsx @@ -384,30 +384,39 @@ const Search: NextPage = ({ translations }): JSX.Element => { }; export const getStaticProps: GetStaticProps = async ({ locale }) => { - const [availableLanguagesResponse, availableTranslationsResponse] = await Promise.all([ - getAvailableLanguages(locale), - getAvailableTranslations(locale), - ]); - - let translations = []; - let languages = []; - if (availableLanguagesResponse.status !== 500) { - const { languages: responseLanguages } = availableLanguagesResponse; - languages = responseLanguages; - } - if (availableTranslationsResponse.status !== 500) { - const { translations: responseTranslations } = availableTranslationsResponse; - translations = responseTranslations; + try { + const [availableLanguagesResponse, availableTranslationsResponse] = await Promise.all([ + getAvailableLanguages(locale, true), + getAvailableTranslations(locale, true), + ]); + + let translations = []; + let languages = []; + if (availableLanguagesResponse.status !== 500) { + const { languages: responseLanguages } = availableLanguagesResponse; + languages = responseLanguages; + } + if (availableTranslationsResponse.status !== 500) { + const { translations: responseTranslations } = availableTranslationsResponse; + translations = responseTranslations; + } + const chaptersData = await getAllChaptersData(locale); + + return { + props: { + chaptersData, + languages, + translations, + }, + }; + } catch (e) { + console.log(e); + return { + props: { + hasError: true, + }, + }; } - const chaptersData = await getAllChaptersData(locale); - - return { - props: { - chaptersData, - languages, - translations, - }, - }; }; export default Search; diff --git a/src/utils/api.ts b/src/utils/api.ts index 6bda1c153b..aa5c9622d2 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -1,6 +1,7 @@ import { decamelizeKeys } from 'humps'; import stringify from './qs-stringify'; +import { getBasePath } from './url'; import { Mushaf, MushafLines, QuranFont, QuranFontMushaf } from 'types/QuranReader'; @@ -15,16 +16,26 @@ const API_ROOT_PATH = '/api/qdc'; export const API_HOST = process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' ? PRODUCTION_API_HOST : STAGING_API_HOST; +const { API_GATEWAY_URL } = process.env; + /** * Generates a url to make an api call to our backend * * @param {string} path the path for the call * @param {Record} parameters optional query params, {a: 1, b: 2} is parsed to "?a=1&b=2" + * @param {boolean} [isStaticBuild=false] - Flag indicating if the URL is for a static build. * @returns {string} */ -export const makeUrl = (path: string, parameters?: Record): string => { +export const makeUrl = ( + path: string, + parameters?: Record, + isStaticBuild: boolean = false, +): string => { + const BASE_PATH = getBasePath(); + const API_PROXY = `${BASE_PATH}/api/proxy/content`; + const API_URL = isStaticBuild ? `${API_GATEWAY_URL}/content` : API_PROXY; if (!parameters) { - return `${API_HOST}${API_ROOT_PATH}${path}`; + return `${API_URL}${API_ROOT_PATH}${path}`; } const decamelizedParams = decamelizeKeys(parameters); @@ -32,7 +43,7 @@ export const makeUrl = (path: string, parameters?: Record): str // The following section parses the query params for convenience // E.g. parses {a: 1, b: 2} to "?a=1&b=2" const queryParameters = `?${stringify(decamelizedParams)}`; - return `${API_HOST}${API_ROOT_PATH}${path}${queryParameters}`; + return `${API_URL}${API_ROOT_PATH}${path}${queryParameters}`; }; /** diff --git a/src/utils/apiPaths.ts b/src/utils/apiPaths.ts index 547f73a520..3e293e4f45 100644 --- a/src/utils/apiPaths.ts +++ b/src/utils/apiPaths.ts @@ -55,7 +55,8 @@ export const makeVersesUrl = ( id: string | number, currentLocale: string, params?: Record, -) => makeUrl(`/verses/by_chapter/${id}`, getVersesParams(currentLocale, params)); + isStaticBuild: boolean = false, +) => makeUrl(`/verses/by_chapter/${id}`, getVersesParams(currentLocale, params), isStaticBuild); export const makeByRangeVersesUrl = (currentLocale: string, params?: Record) => makeUrl(`/verses/by_range`, getVersesParams(currentLocale, params)); @@ -67,10 +68,11 @@ export const makeVersesFilterUrl = (params?: Record) => * Compose the url for the translations API. * * @param {string} language + * @param {boolean} isStaticBuild flag indicating if the request is for a static build. * @returns {string} */ -export const makeTranslationsUrl = (language: string): string => - makeUrl('/resources/translations', { language }); +export const makeTranslationsUrl = (language: string, isStaticBuild: boolean = false): string => + makeUrl('/resources/translations', { language }, isStaticBuild); /** * Compose the url for the wbw translations API. @@ -85,10 +87,11 @@ export const makeWordByWordTranslationsUrl = (language: string): string => * Compose the url for the languages API. * * @param {string} language + * @param {boolean} isStaticBuild flag indicating if the request is for a static build. * @returns {string} */ -export const makeLanguagesUrl = (language: string): string => - makeUrl('/resources/languages', { language }); +export const makeLanguagesUrl = (language: string, isStaticBuild: boolean = false): string => + makeUrl('/resources/languages', { language }, isStaticBuild); /** * Compose the url for reciters API. @@ -96,8 +99,11 @@ export const makeLanguagesUrl = (language: string): string => * @param {string} locale the user's language code. * @returns {string} */ -export const makeAvailableRecitersUrl = (locale: string, fields?: string[]): string => - makeUrl('/audio/reciters', { locale, fields }); +export const makeAvailableRecitersUrl = ( + locale: string, + fields?: string[], + isStaticBuild: boolean = false, +): string => makeUrl('/audio/reciters', { locale, fields }, isStaticBuild); export const makeReciterUrl = (reciterId: string, locale: string): string => makeUrl(`/audio/reciters/${reciterId}`, { @@ -118,7 +124,9 @@ export const makeChapterAudioDataUrl = ( reciterId: number, chapter: number, segments: boolean, -): string => makeUrl(`/audio/reciters/${reciterId}/audio_files`, { chapter, segments }); + isStaticBuild: boolean = false, +): string => + makeUrl(`/audio/reciters/${reciterId}/audio_files`, { chapter, segments }, isStaticBuild); export const makeAudioTimestampsUrl = (reciterId: number, verseKey: string) => makeUrl(`/audio/reciters/${reciterId}/timestamp?verse_key=${verseKey}`); diff --git a/src/utils/auth/signature.ts b/src/utils/auth/signature.ts new file mode 100644 index 0000000000..d8e51e400e --- /dev/null +++ b/src/utils/auth/signature.ts @@ -0,0 +1,61 @@ +import CryptoJS from 'crypto-js'; +import { NextApiRequest } from 'next'; + +/** + * Recursively sorts an object and converts it to a string. + * + * @param {any} params - The object to be sorted and converted. + * @returns {string} - The sorted object as a string. + */ +const recursiveSortedObjectToString = (params: any): string => { + let result = ''; + Object.keys(params) + .sort() + .forEach((key) => { + const value = params[key]; + if (typeof value === 'object' && value !== null) { + result = `${result}${key}${recursiveSortedObjectToString(value)}`; + } else { + result = `${result}${key}${value}`; + } + }); + return result; +}; + +/** + * Generates a signature for the given request. + * + * @param {NextApiRequest} req - The request object. + * @returns {{ signature: string; timestamp: string }} - The generated signature and timestamp. + */ +/** + * Generates a signature for the given request. + * + * @param {NextApiRequest} req - The request object. + * @returns {{ signature: string; timestamp: string }} - The generated signature and timestamp. + */ +// Start of Selection +const generateSignature = ( + req: NextApiRequest, + url: string, +): { signature: string; timestamp: string } => { + const currentTimestamp = new Date().getTime().toString(); + let params = {}; + + try { + if (req.method === 'POST' || req.method === 'PUT') { + params = req.body; + } + } catch (err) { + // eslint-disable-next-line no-console + console.error('Error parsing request body:', err); + } + + const rawString = `${url}.${currentTimestamp}${recursiveSortedObjectToString(params)}`; + const signature = CryptoJS.HmacSHA512(rawString, process.env.SIGNATURE_TOKEN); + const encodedSignature = CryptoJS.enc.Base64.stringify(signature); + + return { signature: encodedSignature, timestamp: currentTimestamp }; +}; + +export default generateSignature; diff --git a/src/utils/url.ts b/src/utils/url.ts index 3c7cb89ff0..67a2bda86f 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -53,4 +53,4 @@ export const getBasePath = (): string => * @param {string} path * @returns {string} */ -export const getAuthApiPath = (path: string): string => `/api/proxy/${path}`; +export const getAuthApiPath = (path: string): string => `/api/proxy/auth/${path}`; diff --git a/yarn.lock b/yarn.lock index 8808c62660..d0a8104200 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6131,6 +6131,11 @@ dependencies: "@types/node" "*" +"@types/crypto-js@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@types/crypto-js/-/crypto-js-4.2.2.tgz#771c4a768d94eb5922cc202a3009558204df0cea" + integrity sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ== + "@types/debug@^4.0.0": version "4.1.12" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" @@ -8470,6 +8475,11 @@ crypto-browserify@^3.12.0: randombytes "^2.0.0" randomfill "^1.0.3" +crypto-js@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" + integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== + crypto-random-string@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"