From a0df82fc32986552780d54881d34b2853f97fe21 Mon Sep 17 00:00:00 2001 From: Tim Hostetler <6970899+thostetler@users.noreply.github.com> Date: Thu, 21 Mar 2024 05:38:19 -0400 Subject: [PATCH] feat: Refactor middleware functions for better organization and clarity Consolidated import statements for NextRequest and NextResponse. Added import statements for middleware functions from their respective files. Removed unnecessary imports and constants. Refactored middleware function for improved readability. Added edgeLogger for logging middleware actions. Separated handling of verify requests into a dedicated function. Improved error handling and validation checks within middleware. --- global.d.ts | 6 +- src/api/__tests__/api.test.ts | 2 +- src/auth-utils.ts | 2 +- src/middleware.ts | 515 +++++---------------------- src/middleware/validateMiddleware.ts | 16 - src/middlewares/botCheck.ts | 77 ++++ src/middlewares/decorateHeaders.ts | 61 ++++ src/middlewares/initSession.ts | 157 ++++++++ src/middlewares/verifyMiddleware.ts | 98 +++++ src/pages/api/isBot.ts | 2 +- src/ssr-utils.ts | 13 +- 11 files changed, 493 insertions(+), 456 deletions(-) delete mode 100644 src/middleware/validateMiddleware.ts create mode 100644 src/middlewares/botCheck.ts create mode 100644 src/middlewares/decorateHeaders.ts create mode 100644 src/middlewares/initSession.ts create mode 100644 src/middlewares/verifyMiddleware.ts diff --git a/global.d.ts b/global.d.ts index 7a1b53bb4..8c98f5580 100644 --- a/global.d.ts +++ b/global.d.ts @@ -15,7 +15,7 @@ declare module 'iron-session' { username: string; }; isAuthenticated?: boolean; - apiCookieHash?: number[]; + apiCookieHash?: string; bot?: boolean; } } @@ -43,7 +43,11 @@ declare global { NEXT_PUBLIC_GTM_ID: string; NEXT_PUBLIC_RECAPTCHA_SITE_KEY: string; NEXT_PUBLIC_API_MOCKING: string; + NEXT_PUBLIC_SENTRY_DSN: string; + NEXT_PUBLIC_SENTRY_PROJECT_ID: string; GIT_SHA: string; + CSP_REPORT_URI: string; + CSP_REPORT_ONLY: string; } } } diff --git a/src/api/__tests__/api.test.ts b/src/api/__tests__/api.test.ts index ff590a1e4..4de050629 100644 --- a/src/api/__tests__/api.test.ts +++ b/src/api/__tests__/api.test.ts @@ -60,7 +60,7 @@ test('basic request calls bootstrap and adds auth', async ({ server }: TestConte // first request was intercepted and bootstrapped expect(urls(onReq)[0]).toEqual(API_USER); // the refresh header was added to force a new session - expect(onReq.mock.calls[0][0].headers.get('X-RefreshToken')).toEqual('1'); + expect(onReq.mock.calls[0][0].headers.get('X-Refresh-Token')).toEqual('1'); const expectedToken = (JSON.parse(onRes.mock.calls[0][0].body) as IApiUserResponse).user.access_token; diff --git a/src/auth-utils.ts b/src/auth-utils.ts index 42bb59184..e21bca0af 100644 --- a/src/auth-utils.ts +++ b/src/auth-utils.ts @@ -46,7 +46,7 @@ export const hash = async (str?: string) => { } try { const buffer = await globalThis.crypto.subtle.digest('SHA-1', Buffer.from(str, 'utf-8')); - return Array.from(new Uint8Array(buffer)); + return Array.from(new Uint8Array(buffer)).toString(); } catch (e) { return null; } diff --git a/src/middleware.ts b/src/middleware.ts index debb9cda0..0aa959e9a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,475 +1,124 @@ // eslint-disable-next-line @next/next/no-server-import-in-page -import type { NextRequest } from 'next/server'; -// eslint-disable-next-line @next/next/no-server-import-in-page -import { NextResponse, userAgent } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; +import { decorateHeaders } from '@middlewares/decorateHeaders'; +import { initSession } from '@middlewares/initSession'; import { getIronSession } from 'iron-session/edge'; -import { ApiTargets } from '../src/api/models'; -import { equals, isNil, pick } from 'ramda'; -import { IBootstrapPayload, IUserData, IVerifyAccountResponse } from '@api'; -import { isPast, parseISO } from 'date-fns'; -import { AUTH_EXCEPTIONS, PROTECTED_ROUTES, sessionConfig } from '@config'; -import { IronSession } from 'iron-session'; -import { logger } from '../logger/logger'; - -const log = logger.child({ module: 'middleware' }); - -const logRequest = (req: NextRequest) => { - log.info({ - msg: 'Middleware request', - method: req.method, - url: req.nextUrl.toString(), - }); -}; - -const REPORT_TO_HEADER = [ - { - group: 'csp-sentry', - max_age: 10886400, - endpoints: [ - { - url: 'https://o1060269.ingest.sentry.io/api/6049652/security/?sentry_key=e87ef8ec678b4ad5a2193c5463d386fd', - }, - ], - }, -] as const; - -const CSP_HEADER = ` - default-src 'self' https://o1060269.ingest.sentry.io; - script-src 'self' ${ - process.env.NODE_ENV === 'development' ? `'unsafe-eval'` : '' - } https://www.googletagmanager.com https://www.youtube-nocookie.com; - style-src 'self' ${process.env.NODE_ENV === 'development' ? `'unsafe-inline'` : ''}; - img-src 'self'; - font-src 'self'; - connect-src 'self' https://*.adsabs.harvard.edu https://o1060269.ingest.sentry.io; - frame-src https://www.google.com https://www.recaptcha.net; - frame-ancestors 'self'; - form-action 'self'; - base-uri 'self'; - manifest-src 'self'; - worker-src 'self'; - object-src 'none'; - require-trusted-types-for 'script'; - report-uri https://o1060269.ingest.sentry.io/api/6049652/security/?sentry_key=e87ef8ec678b4ad5a2193c5463d386fd; - report-to csp-sentry; -` as const; - -const cspHeaderString = CSP_HEADER.replace(/\s{2,}/g, ' ').trim(); -const reportToHeader = REPORT_TO_HEADER.map((r) => JSON.stringify(r)).join(' '); - -// This function can be marked `async` if using `await` inside -export async function middleware(req: NextRequest) { - logRequest(req); - log.debug({ msg: 'IP address', ip: req.ip }); - - if (req.nextUrl.pathname.startsWith('/monitor')) { - return handleMonitorResponse(req); - } - - // apply the CSP header to the request - const requestHeaders = new Headers(); - requestHeaders.set('Report-To', reportToHeader); - requestHeaders.set('Content-Security-Policy-Report-Only', cspHeaderString); - - // get the current session - const res = NextResponse.next({ - headers: requestHeaders, - }); - - // apply the CSP to the response - res.headers.set('Report-To', reportToHeader); - res.headers.set('Content-Security-Policy-Report-Only', cspHeaderString); - const session = await getIronSession(req, res, sessionConfig); - const adsSessionCookie = req.cookies.get(process.env.ADS_SESSION_COOKIE_NAME)?.value; - const apiCookieHash = await hash(adsSessionCookie); - const refresh = req.headers.has('x-RefreshToken'); - - log.debug({ - url: req.nextUrl.toString(), - refresh, - hasAccessToRoute: hasAccessToRoute(req.nextUrl.pathname, session.isAuthenticated), - }); +import { sessionConfig } from '@config'; +import { edgeLogger } from 'logger/logger'; +import { verifyMiddleware } from '@middlewares/verifyMiddleware'; - // verify requests need to be handled separately - if (req.nextUrl.pathname.startsWith('/user/account/verify')) { - return handleVerifyResponse(req, res, session); - } +const log = edgeLogger.child({}, { msgPrefix: '[middleware] ' }); - // check if the token held in the session is valid, and the request has a session - if ( - !session.bot && - !refresh && - isValidToken(session.token) && - // check if the cookie hash matches the one in the session - apiCookieHash !== null && - equals(apiCookieHash, session.apiCookieHash) - ) { - log.debug('session is valid, continuing'); - return handleResponse(req, res, session); - } - - // if the request is from a bot, we need to handle it differently - if (req.ip) { - log.debug({ - msg: 'session is invalid, checking if request is from a crawler', - session, - refresh, - hasAccessToRoute: hasAccessToRoute(req.nextUrl.pathname, session.isAuthenticated), - }); - const crawlerResult = await crawlerCheck(req); - if (crawlerResult !== CRAWLER_RESULT.HUMAN) { - return handleBotResponse({ req, res, session, crawlerResult }); - } - } else if (userAgent(req).isBot) { - log.debug({ msg: 'request has a known bot useragent string, but no IP, so unable to verify' }); - return handleBotResponse({ req, res, session, crawlerResult: CRAWLER_RESULT.UNVERIFIABLE }); - } - log.debug({ msg: 'request is from a human' }); - log.debug({ msg: 'session is invalid, bootstrapping' }); - - // bootstrap a new token, passing in the current session cookie value - const { token, headers } = (await bootstrap(adsSessionCookie)) ?? {}; - - // validate token, update session, forward cookies - if (isValidToken(token)) { - session.token = token; - session.isAuthenticated = isAuthenticated(token); - session.apiCookieHash = await hash( - // grab only the value of the cookie, not the name or the metadata - headers - .get('set-cookie') - .slice(process.env.ADS_SESSION_COOKIE_NAME.length + 1) - .split(';')[0], - ); - res.headers.set('set-cookie', headers.get('set-cookie')); - await session.save(); - - log.debug('Bootstrap successful, responding now'); - - return handleResponse(req, res, session); - } - - // if bootstrapping fails, we should probably redirect back to root and show a message - const url = req.nextUrl.clone(); - url.pathname = '/'; - url.searchParams.set('notify', 'api-connect-failed'); - return NextResponse.redirect(url, { status: 307, ...res }); -} - -export const config = { - matcher: [ - /* - * Match all request paths except for the ones starting with: - * - api (API routes) - * - _next/static (static files) - * - _next/image (image optimization files) - * - favicon.ico (favicon file) - */ - '/((?!api|_next/static|_next/image|favicon|android|images|mockServiceWorker|site.webmanifest).*)', - '/api/user', - '/', - ], -}; - -const hash = async (str?: string) => { - if (!str) { - return null; - } - try { - const buffer = await globalThis.crypto.subtle.digest('SHA-1', Buffer.from(str, 'utf-8')); - return Array.from(new Uint8Array(buffer)); - } catch (e) { - return null; +const redirect = (url: URL, req: NextRequest, message?: string) => { + // clean the url of any existing notify params + url.searchParams.delete('notify'); + if (message) { + url.searchParams.set('notify', message); } + return NextResponse.redirect(url, req); }; -const bootstrap = async (cookie?: string) => { - if (process.env.NEXT_PUBLIC_API_MOCKING === 'enabled') { - return { - token: { - access_token: 'mocked', - username: 'mocked', - anonymous: false, - expire_in: 'mocked', - }, - headers: new Headers({ - 'set-cookie': `${process.env.ADS_SESSION_COOKIE_NAME}=mocked`, - }), - }; - } - - const url = `${process.env.API_HOST_SERVER}${ApiTargets.BOOTSTRAP}`; - const headers = new Headers(); +const redirectIfAuthenticated = async (req: NextRequest, res: NextResponse) => { + const session = await getIronSession(req, res, sessionConfig); - // use the incoming session cookie to perform the bootstrap - if (cookie) { - headers.append('cookie', `${process.env.ADS_SESSION_COOKIE_NAME}=${cookie}`); - } - try { - const res = await fetch(url, { - method: 'GET', - headers, - }); - const json = (await res.json()) as IBootstrapPayload; - return { - token: pick(['access_token', 'username', 'anonymous', 'expire_in'], json) as IUserData, - headers: res.headers, - }; - } catch (e) { - return null; + // if the user is authenticated, redirect them to the root + if (session.isAuthenticated) { + const url = req.nextUrl.clone(); + url.pathname = '/'; + return redirect(url, req); } + return res; }; -const isUserData = (userData?: IUserData): userData is IUserData => { - return ( - !isNil(userData) && - typeof userData.access_token === 'string' && - typeof userData.expire_in === 'string' && - userData.access_token.length > 0 && - userData.expire_in.length > 0 - ); -}; - -const isValidToken = (userData?: IUserData): boolean => { - return isUserData(userData) && !isPast(parseISO(userData.expire_in)); -}; - -const isAuthenticated = (user: IUserData) => isUserData(user) && (!user.anonymous || user.username !== 'anonymous@ads'); - -const checkPrefix = (route: string) => (prefix: string) => !route.startsWith(prefix); -const hasAccessToRoute = (route: string, isAuthenticated: boolean) => { - return isAuthenticated ? AUTH_EXCEPTIONS.every(checkPrefix(route)) : PROTECTED_ROUTES.every(checkPrefix(route)); -}; - -enum CRAWLER_RESULT { - BOT, - HUMAN, - POTENTIAL_MALICIOUS_BOT, - UNVERIFIABLE, -} - -const crawlerCheck = async (req: NextRequest) => { - const res = await fetch(new URL('/api/isBot', req.nextUrl), { - method: 'POST', - body: JSON.stringify({ - ua: userAgent(req).ua, - ip: req.ip, - }), - }); - return (await res.json()) as CRAWLER_RESULT; -}; - -const handleBotResponse = async ({ - req, - res, - session, - crawlerResult, -}: { - req: NextRequest; - res: NextResponse; - session: IronSession; - crawlerResult: CRAWLER_RESULT; -}) => { - const ua = userAgent(req).ua; - if (crawlerResult === CRAWLER_RESULT.BOT) { - log.info({ - msg: 'request is from a bot, applying token', - result: crawlerResult, - ua, - ip: req.ip, - }); - session.token = { - access_token: process.env.VERIFIED_BOTS_ACCESS_TOKEN, - anonymous: true, - expire_in: '9999-01-01T00:00:00', - username: 'anonymous', - }; - } else if (crawlerResult === CRAWLER_RESULT.UNVERIFIABLE) { - log.info({ - msg: 'request is from an unverifiable bot, applying token', - result: crawlerResult, - ua, - ip: req.ip, - }); - session.token = { - access_token: process.env.UNVERIFIABLE_BOTS_ACCESS_TOKEN, - anonymous: true, - expire_in: '9999-01-01T00:00:00', - username: 'anonymous', - }; - } else if (crawlerResult === CRAWLER_RESULT.POTENTIAL_MALICIOUS_BOT) { - log.info({ - msg: 'request is from a potential malicious bot, applying token', - result: crawlerResult, - ua, - ip: req.ip, - }); - session.token = { - access_token: process.env.MALICIOUS_BOTS_ACCESS_TOKEN, - anonymous: true, - expire_in: '9999-01-01T00:00:00', - username: 'anonymous', - }; - } - session.isAuthenticated = false; - session.apiCookieHash = []; - session.bot = true; - await session.save(); - - return handleResponse(req, res, session); -}; +const loginMiddleware = async (req: NextRequest, res: NextResponse) => { + log.debug('Login middleware'); + const session = await getIronSession(req, res, sessionConfig); -const handleResponse = (req: NextRequest, res: NextResponse, session: IronSession) => { - const pathname = req.nextUrl.pathname; - const authenticated = isAuthenticated(session.token); + if (session.isAuthenticated) { + log.debug('User is authenticated, checking for presence of next param'); - // If the user is authenticated, and they are on the login page, redirect them to the root - if (pathname === '/user/account/login' && authenticated) { const next = req.nextUrl.searchParams.get('next'); if (next) { - // if there is a next param, redirect to it - const url = new URL(decodeURIComponent(next), req.nextUrl.origin); - - // this url MUST be relative, otherwise we should redirect to root - if (url.origin !== req.nextUrl.origin) { - // url is external, ignore it and redirect to root - const url = req.nextUrl.clone(); + log.debug({ + msg: 'Next param found', + nextParam: next, + }); + const nextUrl = new URL(decodeURIComponent(next), req.nextUrl.origin); + const url = req.nextUrl.clone(); + if (nextUrl.origin !== url.origin) { + log.debug('Next param is external, redirecting to root'); url.searchParams.delete('next'); url.pathname = '/'; - return redirect(url, res, 'account-login-success'); + return redirect(url, req, 'account-login-success'); } - // if the url is relative, redirect to it - return redirect(url, res, 'account-login-success'); - } else { - // otherwise redirect to root - const url = req.nextUrl.clone(); - url.pathname = '/'; - return redirect(url, res, 'account-login-success'); + log.debug('Next param is relative, redirecting to it'); + url.searchParams.delete('next'); + url.pathname = nextUrl.pathname; + return redirect(url, req, 'account-login-success'); } - } - // request is authenticated, cannot access register - if (pathname === '/user/account/register' && authenticated) { + log.debug('No next param found, redirecting to root'); const url = req.nextUrl.clone(); url.pathname = '/'; - return redirect(url, res); - } - - // request is not authenticated, but the user is trying to access a protected route - if ( - !authenticated && - pathname !== '/user/account/login' && - pathname !== '/user/account/register' && - pathname.startsWith('/user') - ) { - const url = req.nextUrl.clone(); - url.pathname = '/user/account/login'; - url.searchParams.set('next', encodeURIComponent(pathname)); - return redirect(url, res, 'login-required'); + return redirect(url, req); } + log.debug('User is not authenticated, proceeding to login page'); return res; }; -const handleVerifyResponse = async (req: NextRequest, res: NextResponse, session: IronSession) => { - // verify requests have a token we need to send to the API - try { - const [, , , , route, token] = req.nextUrl.pathname.split('/'); - - if (route === 'change-email' || route === 'register') { - // we need to verify the token, and then pass the authenticated session to home page. - // the middleware should run on the home page and bootstrap the session - return await verify({ token, session, res, req }); - } else if (route === 'reset-password') { - // reset password needs to prompt for a new password, allow the request to continue - return res; - } - } catch (e) { - const url = req.nextUrl.clone(); - url.pathname = '/'; - return redirect(url, res, 'verify-token-invalid'); +const protectedRoute = async (req: NextRequest, res: NextResponse) => { + log.debug('Accessing protected route'); + const session = await getIronSession(req, res, sessionConfig); + if (session.isAuthenticated) { + log.debug('User is authenticated, proceeding'); + return res; } + log.debug('User is not authenticated, redirecting to login'); + const url = req.nextUrl.clone(); + const originalPath = url.pathname; + url.pathname = '/user/account/login'; + url.searchParams.set('next', encodeURIComponent(originalPath)); + return redirect(url, req, 'login-required'); }; -const verify = async (options: { token: string; req: NextRequest; res: NextResponse; session: IronSession }) => { - const { req, res, session, token } = options; - // get a new url ready to go, we'll redirect with a message depending on status - const newUrl = req.nextUrl.clone(); - newUrl.pathname = '/'; - - try { - const url = `${process.env.API_HOST_SERVER}${ApiTargets.VERIFY}/${token}`; - const headers = new Headers({ - authorization: `Bearer ${session.token.access_token}`, - cookie: `${process.env.ADS_SESSION_COOKIE_NAME}=${req.cookies.get(process.env.ADS_SESSION_COOKIE_NAME)?.value}`, - }); - - const result = await fetch(url, { - method: 'GET', - headers, - }); - - const json = (await result.json()) as IVerifyAccountResponse; - - if (json.message === 'success') { - // apply the session cookie to the response - res.headers.set('set-cookie', result.headers.get('set-cookie')); - newUrl.pathname = '/user/account/login'; - return redirect(newUrl, res, 'verify-account-success'); - } +export async function middleware(req: NextRequest) { + log.info({ + msg: 'Request', + method: req.method, + url: req.nextUrl.toString(), + }); + const res = NextResponse.next(); - // known error messages - if (json?.error.indexOf('unknown verification token') > -1) { - return redirect(newUrl, res, 'verify-account-failed'); - } + // root middlewares + await decorateHeaders(req); + await initSession(req, res); - if (json?.error.indexOf('already been validated') > -1) { - return redirect(newUrl, res, 'verify-account-was-valid'); - } + const path = req.nextUrl.pathname; - // unknown error - return redirect(newUrl, res, 'verify-account-failed'); - } catch (e) { - return redirect(newUrl, res, 'verify-account-failed'); + if (path.startsWith('/user/account/login')) { + return loginMiddleware(req, res); } -}; -const redirect = (url: URL, res: NextResponse, message?: string) => { - if (message) { - url.searchParams.set('notify', message); + if (path.startsWith('/user/account/register') || path.startsWith('/user/forgotpassword')) { + return redirectIfAuthenticated(req, res); } - return NextResponse.redirect(url, { status: 307, ...res }); -}; - -// Sentry.io tunneling -const SENTRY_HOST = 'o1060269.ingest.sentry.io'; -const SENTRY_PROJECT_IDS = ['6049652']; -const handleMonitorResponse = async (req: NextRequest) => { - try { - const envelope = await req.text(); - const piece = envelope.split('\n')[0]; - const header = JSON.parse(piece) as Record; - const dsn = new URL(header['dsn']); - const project_id = dsn.pathname?.replace('/', ''); - if (dsn.hostname !== SENTRY_HOST) { - throw new Error(`Invalid sentry hostname: ${dsn.hostname}`); - } + if (path.startsWith('/user/libraries') || path.startsWith('/user/settings')) { + return protectedRoute(req, res); + } - if (!project_id || !SENTRY_PROJECT_IDS.includes(project_id)) { - throw new Error(`Invalid sentry project id: ${project_id}`); - } + if (path.startsWith('/user/account/verify/change-email') || path.startsWith('/user/account/verify/register')) { + return verifyMiddleware(req, res); + } - const upstream_sentry_url = `https://${SENTRY_HOST}/api/${project_id}/envelope/`; - await fetch(upstream_sentry_url, { method: 'POST', body: envelope }); + return res; +} - return NextResponse.json({ status: 200 }); - } catch (e) { - log.error({ - msg: 'error tunneling to sentry', - error: e, - }); - return NextResponse.json({ error: 'error tunneling to sentry' }, { status: 500 }); - } +export const config = { + matcher: [ + '/((?!api|_next/static|light|dark|_next/image|favicon|android|images|mockServiceWorker|site.webmanifest).*)', + '/api/user', + ], }; diff --git a/src/middleware/validateMiddleware.ts b/src/middleware/validateMiddleware.ts deleted file mode 100644 index 7826fa686..000000000 --- a/src/middleware/validateMiddleware.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NextFunction } from 'express'; -import { ResultFactory, ValidationChain } from 'express-validator'; -import { NextApiRequest, NextApiResponse } from 'next'; - -export const validateMiddleware = (validations: ValidationChain[], validationResult: ResultFactory) => { - return async (req: NextApiRequest, res: NextApiResponse, next: NextFunction): Promise => { - await Promise.all(validations.map((validation) => validation.run(req))); - - const errors = validationResult(req); - if (errors.isEmpty()) { - return next(); - } - - res.status(422).json({ errors: errors.array() }); - }; -}; diff --git a/src/middlewares/botCheck.ts b/src/middlewares/botCheck.ts new file mode 100644 index 000000000..723a1360e --- /dev/null +++ b/src/middlewares/botCheck.ts @@ -0,0 +1,77 @@ +import { sessionConfig } from '@config'; +import { getIronSession } from 'iron-session/edge'; +import { edgeLogger } from '../../logger/logger'; +// eslint-disable-next-line @next/next/no-server-import-in-page +import { NextRequest, NextResponse, userAgent } from 'next/server'; +import { IronSessionData } from 'iron-session'; + +enum CRAWLER_RESULT { + BOT, + HUMAN, + POTENTIAL_MALICIOUS_BOT, + UNVERIFIABLE, +} + +const getIp = (req: NextRequest) => + req.headers.get('X-Original-Forwarded-For') || + req.headers.get('X-Forwarded-For') || + req.headers.get('X-Real-Ip') || + req.ip; + +const crawlerCheck = async (req: NextRequest, ip: string, ua: string) => { + try { + const res = await fetch(new URL('/api/isBot', req.nextUrl), { + method: 'POST', + body: JSON.stringify({ ua, ip }), + }); + return (await res.json()) as CRAWLER_RESULT; + } catch (e) { + // if the bot check fails, we assume it's a human so we don't restrict a real user because of fetch failure here + return Promise.resolve(CRAWLER_RESULT.HUMAN); + } +}; + +const baseToken: IronSessionData['token'] = { + anonymous: true, + expire_in: '9999-01-01T00:00:00', + username: 'anonymous', + access_token: 'no-token', +}; + +const log = edgeLogger.child({}, { msgPrefix: '[botCheck] ' }); +const getBotToken = (result: CRAWLER_RESULT): IronSessionData['token'] => { + switch (result) { + case CRAWLER_RESULT.BOT: + log.debug('Bot detected'); + return { access_token: process.env.VERIFIED_BOTS_ACCESS_TOKEN, ...baseToken }; + case CRAWLER_RESULT.UNVERIFIABLE: + log.debug('Unverifiable bot detected'); + return { access_token: process.env.UNVERIFIABLE_BOTS_ACCESS_TOKEN, ...baseToken }; + case CRAWLER_RESULT.POTENTIAL_MALICIOUS_BOT: + log.debug('Potentially malicious bot detected'); + return { access_token: process.env.MALICIOUS_BOTS_ACCESS_TOKEN, ...baseToken }; + case CRAWLER_RESULT.HUMAN: + default: + log.debug('Human detected'); + return null; + } +}; + +export const botCheck = async (req: NextRequest, res: NextResponse) => { + const session = await getIronSession(req, res, sessionConfig); + const ua = userAgent(req).ua; + const ip = getIp(req); + const crawlerResult = await crawlerCheck(req, ip, ua); + const token = getBotToken(crawlerResult); + + // if token is set, then it's a bot of some kind + if (token) { + session.token = token; + session.isAuthenticated = false; + session.apiCookieHash = ''; + session.bot = true; + await session.save(); + } + + return res; +}; diff --git a/src/middlewares/decorateHeaders.ts b/src/middlewares/decorateHeaders.ts new file mode 100644 index 000000000..0df63ff58 --- /dev/null +++ b/src/middlewares/decorateHeaders.ts @@ -0,0 +1,61 @@ +// eslint-disable-next-line @next/next/no-server-import-in-page +import { NextRequest, NextResponse } from 'next/server'; +import { edgeLogger } from 'logger/logger'; + +const log = edgeLogger.child({}, { msgPrefix: '[decorateHeaders] ' }); +export const decorateHeaders = async (req: NextRequest) => { + log.debug('Decorating headers'); + const { cspHeader, reportToHeader, nonce } = generateCSP(); + const requestHeaders = new Headers(req.headers); + requestHeaders.set('Report-To', reportToHeader); + requestHeaders.set('X-Nonce', nonce); + requestHeaders.set( + !!process.env.CSP_REPORT_ONLY ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy', + cspHeader, + ); + log.debug('Headers set'); + + return Promise.resolve( + NextResponse.next({ + request: { + headers: requestHeaders, + }, + }), + ); +}; + +const generateCSP = () => { + const nonce = Buffer.from(crypto.randomUUID()).toString('base64'); + const csp = ` + default-src 'self' https://o1060269.ingest.sentry.io; + script-src 'self' ${ + process.env.NODE_ENV === 'development' ? `'unsafe-eval'` : '' + } https://www.googletagmanager.com https://www.youtube-nocookie.com; + style-src 'self' ${process.env.NODE_ENV === 'development' ? `'unsafe-inline'` : ''}; + img-src 'self'; + font-src 'self'; + connect-src 'self' https://*.adsabs.harvard.edu https://o1060269.ingest.sentry.io; + frame-src https://www.google.com https://www.recaptcha.net; + frame-ancestors 'self'; + form-action 'self'; + base-uri 'self'; + manifest-src 'self'; + worker-src 'self'; + object-src 'none'; + require-trusted-types-for 'script'; + report-uri ${process.env.CSP_REPORT_URI} + report-to csp-reporter; + ` as const; + + const reportToHeader = { + group: 'csp-reporter', + max_age: 10886400, + endpoints: [{ url: process.env.CSP_REPORT_URI }], + }; + + return { + nonce, + reportToHeader: JSON.stringify(reportToHeader), + cspHeader: csp.replace(/\s{2,}/g, ' ').trim(), + }; +}; diff --git a/src/middlewares/initSession.ts b/src/middlewares/initSession.ts new file mode 100644 index 000000000..29933f2ea --- /dev/null +++ b/src/middlewares/initSession.ts @@ -0,0 +1,157 @@ +import { getIronSession } from 'iron-session/edge'; +import { sessionConfig } from '@config'; +import { ApiTargets } from '@api/models'; +import { IBootstrapPayload, IUserData } from '@api/user/types'; +import { isNil, pick } from 'ramda'; +import { isPast, parseISO } from 'date-fns'; +import { edgeLogger } from '../../logger/logger'; +// eslint-disable-next-line @next/next/no-server-import-in-page +import { NextRequest, NextResponse } from 'next/server'; +import { botCheck } from '@middlewares/botCheck'; + +/** + * Checks if the user data is valid + * @param userData + */ +const isUserData = (userData?: IUserData): userData is IUserData => + !isNil(userData) && + typeof userData.access_token === 'string' && + typeof userData.expire_in === 'string' && + userData.access_token.length > 0 && + userData.expire_in.length > 0; + +/** + * Checks if the token is valid + * @param userData + */ +const isValidToken = (userData?: IUserData): boolean => isUserData(userData) && !isPast(parseISO(userData.expire_in)); + +/** + * Checks if the user is authenticated + * @param user + */ +const isAuthenticated = (user: IUserData) => isUserData(user) && (!user.anonymous || user.username !== 'anonymous@ads'); + +/** + * Bootstraps the session (to get a new token) + * @param cookie + */ +const bootstrap = async (cookie?: string) => { + if (process.env.NEXT_PUBLIC_API_MOCKING === 'enabled') { + return { + token: { + access_token: 'mocked', + username: 'mocked', + anonymous: false, + expire_in: 'mocked', + }, + headers: new Headers({ + 'set-cookie': `${process.env.ADS_SESSION_COOKIE_NAME}=mocked`, + }), + }; + } + + const url = `${process.env.API_HOST_SERVER}${ApiTargets.BOOTSTRAP}`; + const headers = new Headers(); + + // use the incoming session cookie to perform the bootstrap + if (cookie) { + headers.append('cookie', `${process.env.ADS_SESSION_COOKIE_NAME}=${cookie}`); + } + try { + log.debug('Bootstrapping'); + const res = await fetch(url, { + method: 'GET', + headers, + }); + const json = (await res.json()) as IBootstrapPayload; + log.debug({ + msg: 'Bootstrap successful', + payload: json, + }); + return { + token: pick(['access_token', 'username', 'anonymous', 'expire_in'], json) as IUserData, + headers: res.headers, + }; + } catch (error) { + log.error({ + msg: 'Bootstrapping failed', + error, + }); + return null; + } +}; + +/** + * Hashes a string using SHA-1 + * @param str + */ +const hash = async (str?: string) => { + if (!str) { + return ''; + } + try { + const buffer = await globalThis.crypto.subtle.digest('SHA-1', Buffer.from(str, 'utf-8')); + return Array.from(new Uint8Array(buffer)).toString(); + } catch (e) { + return ''; + } +}; + +const log = edgeLogger.child({}, { msgPrefix: '[initSession] ' }); + +/** + * Middleware to initialize the session + * @param req + * @param res + */ +export const initSession = async (req: NextRequest, res: NextResponse) => { + log.debug('Initializing session'); + const session = await getIronSession(req, res, sessionConfig); + const adsSessionCookie = req.cookies.get(process.env.ADS_SESSION_COOKIE_NAME)?.value; + const apiCookieHash = await hash(adsSessionCookie); + + log.debug('Incoming session found, validating...'); + + // if the session is valid, do not bootstrap + if ( + // if the user has already been identified as a bot, we don't need to re-bootstrap + (session.bot && isValidToken(session.token)) || + // check if the refresh token is present, meaning we want to force a new session + (!req.headers.has('X-Refresh-Token') && + // check if the token is valid + isValidToken(session.token) && + // check if the cookie hash matches the one in the session + apiCookieHash !== null && + // the incoming cookie hash matches the one in the session + apiCookieHash === session.apiCookieHash) + ) { + log.debug('Session is valid.'); + return res; + } + log.debug('Session is invalid, or expired, creating new one...'); + + // check if the user is a bot + await botCheck(req, res); + + // bootstrap a new token, passing in the current session cookie value + const { token, headers } = (await bootstrap(adsSessionCookie)) ?? {}; + + // validate token, update session, forward cookies + if (isValidToken(token)) { + session.token = token; + session.isAuthenticated = isAuthenticated(token); + res.cookies.set(process.env.ADS_SESSION_COOKIE_NAME, headers.get('set-cookie') ?? ''); + session.apiCookieHash = await hash(res.cookies.get(process.env.ADS_SESSION_COOKIE_NAME)?.value); + await session.save(); + log.debug('New session created.'); + return res; + } + + log.error('Failed to create a new session, redirecting back to root'); + // if bootstrapping fails, we should probably redirect back to root and show a message + const url = req.nextUrl.clone(); + url.pathname = '/'; + url.searchParams.set('notify', 'api-connect-failed'); + return NextResponse.redirect(url, req); +}; diff --git a/src/middlewares/verifyMiddleware.ts b/src/middlewares/verifyMiddleware.ts new file mode 100644 index 000000000..599dcfbc4 --- /dev/null +++ b/src/middlewares/verifyMiddleware.ts @@ -0,0 +1,98 @@ +// eslint-disable-next-line @next/next/no-server-import-in-page +import { NextRequest, NextResponse } from 'next/server'; +import { getIronSession } from 'iron-session/edge'; +import { sessionConfig } from '@config'; +import { edgeLogger } from 'logger/logger'; +import { ApiTargets } from '@api/models'; +import { IVerifyAccountResponse } from '@api/user/types'; + +const extractToken = (path: string) => { + try { + if (typeof path === 'string') { + const parts = path.split('/'); + const token = parts.pop(); + const route = parts.pop(); + return { token, route }; + } + return { route: '', token: '' }; + } catch (e) { + return { route: '', token: '' }; + } +}; + +const log = edgeLogger.child({}, { msgPrefix: '[verifyMiddleware] ' }); +export const verifyMiddleware = async (req: NextRequest, res: NextResponse) => { + log.debug('Handling verify request'); + const session = await getIronSession(req, res, sessionConfig); + const { route, token } = extractToken(req.nextUrl.pathname); + const newUrl = req.nextUrl.clone(); + newUrl.pathname = '/'; + + if (route === 'change-email' || route === 'register') { + log.debug({ + msg: 'Verifying token', + route, + }); + + try { + const url = `${process.env.API_HOST_SERVER}${ApiTargets.VERIFY}/${token}`; + const headers = new Headers({ + authorization: `Bearer ${session.token.access_token}`, + cookie: `${process.env.ADS_SESSION_COOKIE_NAME}=${req.cookies.get(process.env.ADS_SESSION_COOKIE_NAME)?.value}`, + }); + + const result = await fetch(url, { + method: 'GET', + headers, + }); + + const json = (await result.json()) as IVerifyAccountResponse; + + if (json.message === 'success') { + log.debug('Token was verified, redirecting...'); + // apply the session cookie to the response + res.headers.set('set-cookie', result.headers.get('set-cookie')); + newUrl.pathname = '/user/account/login'; + return redirect(newUrl, req, 'verify-account-success'); + } + + // known error messages + if (json?.error.indexOf('unknown verification token') > -1) { + log.error({ + msg: 'Token was invalid, verify failed, redirecting...', + error: json?.error, + }); + return redirect(newUrl, req, 'verify-account-failed'); + } + + if (json?.error.indexOf('already been validated') > -1) { + log.error({ + msg: 'Token was already validated, redirecting...', + error: json?.error, + }); + return redirect(newUrl, req, 'verify-account-was-valid'); + } + + log.error({ + msg: 'Unknown issue, unable to verify, redirecting...', + error: json?.error, + }); + return redirect(newUrl, req, 'verify-account-failed'); + } catch (error) { + log.error({ + msg: 'Unknown issue, unable to verify, redirecting...', + error, + }); + return redirect(newUrl, req, 'verify-account-failed'); + } + } +}; + +const redirect = (url: URL, req: NextRequest, message?: string) => { + // clean the url of any existing notify params + url.searchParams.delete('notify'); + if (message) { + url.searchParams.set('notify', message); + } + return NextResponse.redirect(url, req); +}; diff --git a/src/pages/api/isBot.ts b/src/pages/api/isBot.ts index 364189f93..eeb073697 100644 --- a/src/pages/api/isBot.ts +++ b/src/pages/api/isBot.ts @@ -25,7 +25,7 @@ export const isBot: NextApiHandler = async (req, res) => { const evaluate = (ua: string, remoteIP: string) => { if (typeof remoteIP !== 'string' || remoteIP.length <= 0) { log.debug('Request IP is not a string or is empty', { remoteIP }); - return RESULT.UNVERIFIABLE; + return RESULT.HUMAN; } return classify(ua, remoteIP); }; diff --git a/src/ssr-utils.ts b/src/ssr-utils.ts index 0d330e386..7d6dfcd59 100644 --- a/src/ssr-utils.ts +++ b/src/ssr-utils.ts @@ -8,7 +8,13 @@ import { dehydrate, hydrate, QueryClient } from '@tanstack/react-query'; import { getNotification, NotificationId } from '@store/slices'; import { logger } from '../logger/logger'; -const log = logger.child({ module: 'ssr-inject' }); +const log = logger.child({}, { msgPrefix: '[ssr-inject] ' }); + +const injectNonce: IncomingGSSP = (ctx, prev) => { + const nonce = ctx.res.getHeader('X-Nonce') ?? ''; + log.debug({ msg: 'Injecting nonce', nonce }); + return Promise.resolve({ props: { nonce, ...prev.props } }); +}; const injectColorModeCookie: IncomingGSSP = (ctx, prev) => { const colorMode = ctx.req.cookies['chakra-ui-color-mode'] ?? ''; @@ -20,8 +26,8 @@ const updateUserStateSSR: IncomingGSSP = (ctx, prevResult) => { const userData = ctx.req.session.token; log.debug({ - msg: 'Injecting session data into SSR', - session: ctx.req.session, + msg: 'Injecting session data into client props', + userData, isValidUserData: isUserData(userData), token: isUserData(userData) ? userData.access_token : null, }); @@ -62,6 +68,7 @@ export const composeNextGSSP = (...fns: IncomingGSSP[]) => withIronSessionSsr(async (ctx: GetServerSidePropsContext): Promise< GetServerSidePropsResult> > => { + fns.push(injectNonce); fns.push(updateUserStateSSR); fns.push(injectColorModeCookie); api.setUserData(ctx.req.session.token);