Skip to content

Commit

Permalink
Merge branch 'develop' into feature/GAP-2420-applicant
Browse files Browse the repository at this point in the history
  • Loading branch information
jgunnCO committed Apr 16, 2024
2 parents c125e75 + 392e8f4 commit 699a210
Show file tree
Hide file tree
Showing 67 changed files with 1,836 additions and 343 deletions.
2 changes: 2 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ module.exports = {
],
plugins: ['eslint-plugin-prettier'],
rules: {
// import the logger util and use this instead - console won't correctly format logs for cloudwatch
'no-console': 'error',
'no-shadow-restricted-names': 'off',
'no-prototype-builtins': 'off',
'prettier/prettier': 'error',
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
"semver": "^7.5.2",
"word-wrap": "^1.2.4",
"@babel/traverse": "7.23.2",
"parse-url": "8.1.0"
"parse-url": "8.1.0",
"@types/react": "^18.2.74"
},
"packageManager": "[email protected]"
}
3 changes: 2 additions & 1 deletion packages/admin/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ LOGOUT_URL=http://localhost:8082/logout
MAX_COOKIE_AGE=21600
ONE_LOGIN_ENABLED=false
ONE_LOGIN_MIGRATION_JOURNEY_ENABLED=false
REFRESH_URL=http://localhost:8082/refresh-token
SESSION_COOKIE_NAME=session_id
SPOTLIGHT_URL=https://cabinetoffice-spotlight.force.com/s/login/
SUB_PATH=/apply/admin
SUPER_ADMIN_DASHBOARD_URL=http://localhost:3001/apply/admin/super-admin-dashboard
USER_SERVICE_URL=http://localhost:8082
V2_LOGIN_URL=http://localhost:8082/v2/login
V2_LOGOUT_URL=http://localhost:8082/v2/logout
VALIDATE_USER_ROLES_IN_MIDDLEWARE=true
VALIDATE_USER_ROLES_IN_MIDDLEWARE=true
4 changes: 3 additions & 1 deletion packages/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"gap-web-ui": "*",
"govuk-frontend": "^4.8",
"moment": "^2.29.4",
"moment-timezone": "^0.5.37",
"next": "13.5.6",
"next-logger": "3.0.1",
"nookies": "^2.5.2",
Expand All @@ -36,6 +37,7 @@
"react-device-detect": "^2.1.2",
"react-dom": "18.2.0",
"react-gtm-module": "^2.0.11",
"react-moment": "^1.1.1",
"tinymce": "6.8.2",
"uuid": "9.0.1"
},
Expand All @@ -49,7 +51,7 @@
"isomorphic-fetch": "^3.0.0",
"sass": "1.62.1",
"typescript": "^4.6.3",
"urlpattern-polyfill": "^9.0.0"
"urlpattern-polyfill": "^10.0.0"
},
"browserslist": {
"production": [
Expand Down
1 change: 1 addition & 0 deletions packages/admin/setupJestMock.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import '@testing-library/jest-dom';
import 'isomorphic-fetch';
import 'urlpattern-polyfill';

jest.mock('next/head', () => {
return {
Expand Down
224 changes: 168 additions & 56 deletions packages/admin/src/middleware.page.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,183 @@
// eslint-disable-next-line @next/next/no-server-import-in-page
import { NextRequest, NextResponse, URLPattern } from 'next/server';
import { v4 } from 'uuid';
import { getLoginUrl, parseJwt } from './utils/general';
import { isAdminSessionValid } from './services/UserService';
import { csrfMiddleware } from './utils/csrfMiddleware';
import { getLoginUrl } from './utils/general';

// It will apply the middleware to all those paths
// (if new folders at page root are created, they need to be included here)
export const config = {
matcher: [
'/build-application/:path*',
'/dashboard/:path*',
'/new-scheme/:path*',
'/scheme/:path*',
'/scheme-list/:path*',
'/super-admin-dashboard/:path*',
],
};

export async function middleware(req: NextRequest) {
const rewriteUrl = req.url;
const res = NextResponse.rewrite(rewriteUrl);
await csrfMiddleware(req, res);
import { logger } from './utils/logger';
import { HEADERS } from './utils/constants';

const auth_cookie = req.cookies.get('session_id');
//Feature flag redirects
const advertBuilderPath = /\/scheme\/\d*\/advert/;
const authenticateRequest = async (req: NextRequest, res: NextResponse) => {
const authCookie = req.cookies.get('session_id');
const userJwtCookie = req.cookies.get(process.env.JWT_COOKIE_NAME);

if (process.env.FEATURE_ADVERT_BUILDER === 'disabled') {
if (advertBuilderPath.test(req.nextUrl.pathname)) {
const url = req.nextUrl.clone();
url.pathname = `/404`;
return NextResponse.redirect(url);
const isLoggedIn = !!userJwtCookie;
const isLoggedInAsAdmin = !!authCookie;
// means that the user is logged in but not as an admin/superAdmin (otherwise they would have had the session_id cookie set)
if (isLoggedIn && !isLoggedInAsAdmin) {
const url = getLoginUrl({ redirectTo404: true });
logger.info(
`User is not an admin - redirecting it to appropriate app 404 page`
);
return NextResponse.redirect(url, { status: 302 });
} else if (!isLoggedIn || !isLoggedInAsAdmin) {
let url = getLoginUrl();
if (isSubmissionExportLink(req)) {
url += `?${generateRedirectUrl(req)}`;
}
logger.info(`Not authorised - logging in via: ${url}`);
return NextResponse.redirect(url, { status: 302 });
}

if (auth_cookie !== undefined) {
if (process.env.VALIDATE_USER_ROLES_IN_MIDDLEWARE === 'true') {
const isValidAdminSession = await isAdminSessionValid(auth_cookie.value);
if (!isValidAdminSession) {
return NextResponse.redirect(
getLoginUrl({ redirectToApplicant: true }),
{ status: 302 }
);
}
}
const isValidSession = await isValidAdminSession(authCookie);
//user has the session_id cookie set but not valid
if (!isValidSession) {
const url = getLoginUrl({ redirectToApplicant: true });
logger.info(`Admin session invalid - logging in via applicant app: ${url}`);
return NextResponse.redirect(url, { status: 302 });
}

res.cookies.set('session_id', auth_cookie.value, {
path: '/',
secure: true,
httpOnly: true,
sameSite: 'lax',
maxAge: parseInt(process.env.MAX_COOKIE_AGE!),
});
if (hasJwtExpired(userJwtCookie)) {
const url = `${getLoginUrl()}?${generateRedirectUrl(req)}`;
logger.info(`Jwt expired - logging in via: ${url}`);
return NextResponse.redirect(url, { status: 302 });
}

res.headers.set('Cache-Control', 'no-store');
return res;
if (isJwtExpiringSoon(userJwtCookie)) {
const url = `${process.env.REFRESH_URL}?${generateRedirectUrl(req)}`;
logger.info(`Refreshing JWT - redircting to: ${url}`);
return NextResponse.redirect(url, { status: 307 });
}
let url = getLoginUrl();
console.log('Middleware redirect URL: ' + url);
if (submissionDownloadPattern.test({ pathname: req.nextUrl.pathname })) {
url = `${url}?redirectUrl=${process.env.HOST}${req.nextUrl.pathname}`;
console.log('Getting submission export download redirect URL: ' + url);

if (isAdBuilderRedirectAndDisabled(req)) {
const url = req.nextUrl.clone();
url.pathname = '/404';
logger.info(`Ad builder disabled - redirecting to 404: ${url.toString()}`);
return NextResponse.redirect(url, { status: 302 });
}
console.log('Final redirect URL from admin middleware: ' + url);
return NextResponse.redirect(url);

logger.info('User is authorised');
addAdminSessionCookie(res, authCookie);
res.headers.set('Cache-Control', 'no-store');
return res;
};

const httpLoggers = {
req: (req: NextRequest) => {
const correlationId = v4();
req.headers.set(HEADERS.CORRELATION_ID, correlationId);
logger.http('Incoming request', {
...logger.utils.formatRequest(req),
correlationId,
});
},
res: (req: NextRequest, res: NextResponse) =>
logger.http(
'Outgoing response - PLEASE NOTE: this represents a snapshot of the response as it exits the middleware, changes made by other server code (eg getServerSideProps) will not be shown',
{
...logger.utils.formatResponse(res),
correlationId: req.headers.get(HEADERS.CORRELATION_ID),
}
),
};

type LoggerType = keyof typeof httpLoggers;

const urlsToSkip = ['/_next/', '/assets/', '/javascript/'];

const getConditionalLogger = (req: NextRequest, type: LoggerType) => {
const userAgentHeader = req.headers.get('user-agent') || '';
const shouldSkipLogging =
userAgentHeader.startsWith('ELB-HealthChecker') ||
urlsToSkip.some((url) => req.nextUrl.pathname.startsWith(url));
return shouldSkipLogging ? () => undefined : httpLoggers[type];
};

const authenticatedPaths = [
'/build-application/:path*',
'/dashboard/:path*',
'/new-scheme/:path*',
'/scheme/:path*',
'/scheme-list/:path*',
'/super-admin-dashboard/:path*',
].map((pathname) => new URLPattern({ pathname }));

const isAuthenticatedPath = (pathname: string) =>
authenticatedPaths.some((authenticatedPath) =>
authenticatedPath.test({ pathname })
);

export async function middleware(req: NextRequest) {
const logRequest = getConditionalLogger(req, 'req');
const logResponse = getConditionalLogger(req, 'res');
let res = NextResponse.next();
logRequest(req, res);

if (isAuthenticatedPath(req.nextUrl.pathname)) {
res = await authenticateRequest(req, res);
await csrfMiddleware(req, res);
}
logResponse(req, res);
return res;
}

function generateRedirectUrl(req: NextRequest) {
const redirectUrl = process.env.HOST + req.nextUrl.pathname;
const redirectUrlSearchParams = encodeURIComponent(
req.nextUrl.searchParams.toString()
);
return `redirectUrl=${redirectUrl}${
redirectUrlSearchParams ? '?' + redirectUrlSearchParams : ''
}`;
}

function addAdminSessionCookie(res: NextResponse, authCookie: RequestCookie) {
res.cookies.set('session_id', authCookie.value, {
path: '/',
secure: true,
httpOnly: true,
sameSite: 'lax',
maxAge: parseInt(process.env.MAX_COOKIE_AGE!),
});
}

function isAdBuilderRedirectAndDisabled(req: NextRequest) {
const advertBuilderPath = /\/scheme\/\d*\/advert/;
return (
process.env.FEATURE_ADVERT_BUILDER === 'disabled' &&
advertBuilderPath.test(req.nextUrl.pathname)
);
}

function isSubmissionExportLink(req: NextRequest) {
const submissionDownloadPattern = new URLPattern({
pathname: '/scheme/:schemeId([0-9]+)/:exportBatchUuid([0-9a-f-]+)',
});
return submissionDownloadPattern.test({ pathname: req.nextUrl.pathname });
}

async function isValidAdminSession(authCookie: RequestCookie) {
if (process.env.VALIDATE_USER_ROLES_IN_MIDDLEWARE !== 'true') return true;
return await isAdminSessionValid(authCookie.value);
}

function hasJwtExpired(jwtCookie: RequestCookie) {
const jwt = parseJwt(jwtCookie.value);
const expiresAt = new Date(jwt.exp * 1000);
const now = new Date();
return expiresAt <= now;
}

function isJwtExpiringSoon(jwtCookie: RequestCookie) {
const jwt = parseJwt(jwtCookie.value);
const expiresAt = new Date(jwt.exp * 1000);
const now = new Date();
const nowPlusMins = new Date();
nowPlusMins.setMinutes(now.getMinutes() + 30);
return expiresAt >= now && expiresAt <= nowPlusMins;
}

const submissionDownloadPattern = new URLPattern({
pathname: '/scheme/:schemeId([0-9]+)/:exportBatchUuid([0-9a-f-]+)',
});
type RequestCookie = Exclude<
ReturnType<NextRequest['cookies']['get']>,
undefined
>;
Loading

0 comments on commit 699a210

Please sign in to comment.