-
Notifications
You must be signed in to change notification settings - Fork 13
/
middleware.ts
338 lines (288 loc) · 11.3 KB
/
middleware.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
import { NextResponse } from "next/server";
import { fallbackLng, languages } from "./i18n/settings";
import type { NextRequest } from "next/server";
import { generateCSP } from "@lib/cspScripts";
import { logMessage } from "@lib/logger";
import { NextAuthRequest } from "next-auth/lib";
import CredentialsProvider from "next-auth/providers/credentials";
import NextAuth, { Session } from "next-auth";
import { JWT } from "next-auth/jwt";
const verboseDebug = false;
const debugLogger = async (message: string) => {
if (verboseDebug) {
logMessage.info(message);
}
};
/**
* We need to instantiate the NextAuth middleware to decrypt the token on the header.
* The normal @lib/auth `auth` function can not be used because it invokes Prisma which is not Edge runtime compatible.
*/
const { auth } = NextAuth({
providers: [
CredentialsProvider({
id: "middleware",
name: "Middleware",
credentials: {},
async authorize() {
return null;
},
}),
],
// When building the app use a random UUID as the token secret
secret: process.env.TOKEN_SECRET ?? crypto.randomUUID(),
debug: process.env.NODE_ENV !== "production",
// Elastic Load Balancer safely sets the host header and ignores the incoming request headers
trustHost: true,
session: {
strategy: "jwt",
// Seconds - How long until an idle session expires and is no longer valid.
updateAge: 30 * 60, // 30 minutes
maxAge: 2 * 60 * 60, // 2 hours
},
callbacks: {
async session(params) {
const { session, token } = params as { session: Session; token: JWT };
// Copy token contents into session for middleware
session.user = {
id: token.userId ?? "",
lastLoginTime: token.lastLoginTime,
acceptableUse: token.acceptableUse ?? false,
name: token.name ?? null,
email: token.email,
privileges: [],
...(token.newlyRegistered && { newlyRegistered: token.newlyRegistered }),
...(token.deactivated && { deactivated: token.deactivated }),
hasSecurityQuestions: token.hasSecurityQuestions ?? false,
};
return session;
},
},
});
export const config = {
/*
Match all request paths except for the ones starting with:
- _next/static (static files)
- _next/image (image optimization files)
- img (public image files)
- static (public static files)
- react_devtools (React DevTools)
- contain files with extentions
*/
matcher:
"/((?!_next/static|_next/image|img|static|react_devtools|unsupported-browser|javascript-disabled|__nextjs_|.*\\.[^/]+?$).*)",
};
// TOMORROW
// Stop files like .map.js from being included in the middleware
export default function middlware(req: NextRequest) {
const pathname = req.nextUrl.pathname;
const searchParams = req.nextUrl.searchParams.toString();
// Layer 0 - Set CORS on API routes
const layer0 = setCORS(req, pathname);
if (layer0) return layer0;
const pathLang = pathname.split("/")[1];
const cookieLang = req.cookies.get("i18next")?.value;
const prefetchedRoute = Boolean(req.headers.get("next-url"));
debugLogger(`Middleware: ${prefetchedRoute ? "PREFECTHED LINK" : ""} path = ${pathname}`);
// Layer 1 - Redirect to language selector if app path is not provided
const layer1 = languageSelectorRedirect(req, pathname, pathLang);
if (layer1) return layer1;
// Layer 2 - Redirect to url with locale if lng in path is not present or supported
const layer2 = addLangToPath(req, pathname, cookieLang, searchParams);
if (layer2) return layer2;
// Add Session Data to the req for the remaining levels
return auth((reqWithAuth) => {
// Layer 3 - Pages with Required Auth
const layer3 = pageRequiresAuth(reqWithAuth, pathname, pathLang);
if (layer3) return layer3;
// Layer 4 - Auth Users Redirect
const layer4 = authFlowRedirect(reqWithAuth, pathname, pathLang, cookieLang);
if (layer4) return layer4;
// Final Layer - Set Content Security Policy
return setCSP(reqWithAuth, pathname, cookieLang, pathLang);
})(req, {});
}
/**
*************************
* LAYERS *
*************************
*/
/**
* Set the CORS headers on API routes
*/
const setCORS = (req: NextRequest, pathname: string) => {
const reqHeaders = new Headers(req.headers);
// Response
if (pathname.startsWith("/api") || reqHeaders.get("next-action")) {
debugLogger(`Middleware: API / Server Action path = ${pathname}`);
const response = NextResponse.next();
const host = reqHeaders.get("host");
// If host header is not set something is seriously wrong.
if (!host) {
throw new Error(
`HOST header is missing from request to ${pathname} from ${req.headers.get(
"x-forwarded-for"
)}`
);
}
// Set CORS headers
response.headers.set("Access-Control-Allow-Origin", host);
response.headers.set("Access-Control-Allow-Credentials", "true");
response.headers.set("Access-Control-Allow-Methods", "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS");
response.headers.set(
"Access-Control-Allow-Headers",
"X-CSRF-Token,X-Requested-With,Accept,Accept-Version,Content-Length,Content-MD5,Content-Type,Date,X-Api-Version"
);
response.headers.set("Access-Control-Max-Age", "86400"); // 60 * 60 * 24 = 24 hours;
response.headers.set("content-security-policy", 'default-src "none"');
debugLogger(`Middleware Action: Setting CORS on API route: ${pathname}`);
return response;
}
};
/**
* Redirect to the language selection page if `/en` or /fr` is the page path
*/
const languageSelectorRedirect = (req: NextRequest, pathname: string, pathLang: string) => {
if (languages.some((lang) => new RegExp(`^/${lang}/?$`).test(pathname))) {
const redirect = NextResponse.redirect(new URL("/", req.url));
// Set cookie on response back to browser so client can render correct language on client components
redirect.cookies.set("i18next", pathLang);
debugLogger(
`Middleware Action: Redirecting to language selector: ${pathname} pathlang: ${pathLang} `
);
return redirect;
}
};
/**
* Ensure the the language param is always in the path.
* Set the language param using the cookie language if param is missing or not supported.
*/
const addLangToPath = (
req: NextRequest,
pathname: string,
cookieLang: string | undefined,
searchParams: string
) => {
if (pathname !== "/" && !languages.some((loc) => new RegExp(`^/${loc}/.+$`).test(pathname))) {
// Check to see if language cookie is present
if (languages.some((lang) => lang === cookieLang)) {
// Cookies language is already supported, redirect to that language
logMessage.debug(
`Middleware Action: Adding language to path: ${cookieLang}, pathname: ${pathname}`
);
return NextResponse.redirect(
new URL(`/${cookieLang}${pathname}${searchParams && "?" + searchParams}`, req.url)
);
} else {
// Redirect to fallback language
debugLogger(`Middleware Action: Adding default language to path: ${pathname}`);
return NextResponse.redirect(new URL(`/${fallbackLng}${pathname}`, req.url));
}
}
};
/**
* Set the Content Security Policy
*/
const setCSP = (
req: NextRequest,
pathname: string,
cookieLang: string | undefined,
pathLang: string
) => {
// Set the Content Security Policy (CSP) header
const { csp, nonce } = generateCSP();
const requestHeaders = new Headers(req.headers);
requestHeaders.set("x-nonce", nonce);
if (process.env.NODE_ENV !== "development") {
// Set the CSP header on the request to the server
requestHeaders.set("content-security-policy", csp);
}
// Set path on request headers so we can access it in the app router
requestHeaders.set("x-path", pathname);
// Create base Next Response with CSP header and i18n cookie
const response = NextResponse.next({
headers: requestHeaders,
});
// Set the CSP header on the response to the browser on the built version of the app only
if (process.env.NODE_ENV !== "development") response.headers.set("content-security-policy", csp);
// Set cookie on response back to browser so client can render correct language on client components
if (pathLang && cookieLang !== pathLang) response.cookies.set("i18next", pathLang);
return response;
};
/**
* Handles the redirection of users in the authentication flow
*/
const authFlowRedirect = (
req: NextAuthRequest,
pathname: string,
pathLang: string | undefined,
cookieLang: string | undefined
) => {
const path = pathname.replace(`/${pathLang}/`, "/");
const lang = pathLang || cookieLang || fallbackLng;
const onAuthFlow = path.startsWith("/auth/mfa") || path.startsWith("/auth/restricted-access");
const onSupport =
path.startsWith("/support") || path.startsWith("/sla") || path.startsWith("/terms-of-use");
const session = req.auth;
const origin = req.nextUrl.origin;
// Ignore if user is in the auth flow of MfA
if (session && !onAuthFlow) {
if (
!session.user.hasSecurityQuestions &&
!path.startsWith("/auth/setup-security-questions") &&
// Let them access support related pages if having issues with Security Questions
!onSupport
) {
logMessage.debug(
"Middlware Action: User has not setup security questions, redirecting to setup-security-questions"
);
// check if user has setup security questions setup
const securityQuestionsPage = new URL(`/${lang}/auth/setup-security-questions`, origin);
debugLogger(`Middleware: Redirecting to ${securityQuestionsPage}`);
return NextResponse.redirect(securityQuestionsPage);
}
// Redirect to policy page only if users aren't on the policy, support, or security questions page
if (
session.user.hasSecurityQuestions &&
!session.user.acceptableUse &&
!onSupport &&
!path.startsWith("/auth/policy") &&
// If they don't want to accept let them log out
!path.startsWith("/auth/logout")
) {
debugLogger(
"Middleware Action: User has not accepted the Acceptable Use Policy, redirecting to policy"
);
// If they haven't agreed to Acceptable Use redirect to policy page for acceptance
// Also check that the path is local and not an external URL
const acceptableUsePage = new URL(`/${lang}/auth/policy`, origin);
return NextResponse.redirect(acceptableUsePage);
}
}
};
/**
* Ensures that a user visiting a page that requires authentication
* is redirected to the login page if they are not authenticated
*/
const pageRequiresAuth = (req: NextAuthRequest, pathname: string, pathLang: string) => {
const path = pathname.replace(`/${pathLang}/`, "/");
const session = req.auth;
const pathsRequiringAuth = [
"/admin",
"/forms",
"/unlock-publishing",
"/profile",
"/auth/setup-security-questions",
"/auth/policy",
"/auth/account-created",
];
const onProtectedPath = pathsRequiringAuth.find((protectedPath) => {
// If the path is the same as the protected path or it starts with the protected path
if (path === protectedPath) return true;
return path.startsWith(protectedPath);
});
if (!session && onProtectedPath) {
debugLogger(`Middleware Action: Redirecting unauthenticated user to login page from ${path}`);
const login = new URL(`/${pathLang}/auth/login`, req.nextUrl.origin);
return NextResponse.redirect(login);
}
};