Implement authentication using Passport.js

This commit is contained in:
Aaron William Po
2023-02-05 19:27:19 -05:00
parent 86f6f9abc5
commit 087a1a4513
26 changed files with 2073 additions and 412 deletions

37
config/auth/cookie.ts Normal file
View File

@@ -0,0 +1,37 @@
import { NextApiResponse } from 'next';
import { serialize, parse } from 'cookie';
import { SessionRequest } from './types';
const TOKEN_NAME = 'token';
export const MAX_AGE = 60 * 60 * 8; // 8 hours
export function setTokenCookie(res: NextApiResponse, token: string) {
const cookie = serialize(TOKEN_NAME, token, {
maxAge: MAX_AGE,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
path: '/',
sameSite: 'lax',
});
res.setHeader('Set-Cookie', cookie);
}
export function removeTokenCookie(res: NextApiResponse) {
const cookie = serialize(TOKEN_NAME, '', { maxAge: -1, path: '/' });
res.setHeader('Set-Cookie', cookie);
}
export function parseCookies(req: SessionRequest) {
// For API Routes we don't need to parse the cookies.
if (req.cookies) return req.cookies;
// For pages we do need to parse the cookies.
const cookie = req.headers?.cookie;
return parse(cookie || '');
}
export function getTokenCookie(req: SessionRequest) {
const cookies = parseCookies(req);
return cookies[TOKEN_NAME];
}

24
config/auth/localStrat.ts Normal file
View File

@@ -0,0 +1,24 @@
import findUserByUsername from '@/services/user/findUserByUsername';
import Local from 'passport-local';
import ServerError from '../util/ServerError';
import { validatePassword } from './passwordFns';
const localStrat = new Local.Strategy(async (username, password, done) => {
try {
const user = await findUserByUsername(username);
if (!user) {
throw new ServerError('Username or password is incorrect.', 401);
}
const isValidLogin = await validatePassword(user.hash, password);
if (!isValidLogin) {
throw new ServerError('Username or password is incorrect.', 401);
}
done(null, { id: user.id, username: user.username });
} catch (error) {
done(error);
}
});
export default localStrat;

View File

@@ -0,0 +1,18 @@
import { NextApiResponse } from 'next';
import { NextHandler } from 'next-connect';
import { getLoginSession } from '../session';
import { ExtendedNextApiRequest } from '../types';
/** Get the current user from the session. Adds the user to the request object. */
const getCurrentUser = async (
req: ExtendedNextApiRequest,
res: NextApiResponse,
next: NextHandler,
) => {
const session = await getLoginSession(req);
const user = { id: session.id, username: session.username };
req.user = user;
next();
};
export default getCurrentUser;

View File

@@ -0,0 +1,6 @@
import argon2 from 'argon2';
export const hashPassword = async (password: string) => argon2.hash(password);
export const validatePassword = async (hash: string, password: string) =>
argon2.verify(hash, password);

49
config/auth/session.ts Normal file
View File

@@ -0,0 +1,49 @@
import { NextApiResponse } from 'next';
import Iron from '@hapi/iron';
import { SessionRequest, UserInfoSchema, UserSessionSchema } from '@/config/auth/types';
import { z } from 'zod';
import { MAX_AGE, setTokenCookie, getTokenCookie } from './cookie';
import ServerError from '../util/ServerError';
const { TOKEN_SECRET } = process.env;
export async function setLoginSession(
res: NextApiResponse,
session: z.infer<typeof UserInfoSchema>,
) {
if (!TOKEN_SECRET) {
throw new ServerError('Authentication is not configured.', 500);
}
const createdAt = Date.now();
const obj = { ...session, createdAt, maxAge: MAX_AGE };
const token = await Iron.seal(obj, TOKEN_SECRET, Iron.defaults);
setTokenCookie(res, token);
}
export async function getLoginSession(req: SessionRequest) {
if (!TOKEN_SECRET) {
throw new ServerError('Authentication is not configured.', 500);
}
const token = getTokenCookie(req);
if (!token) {
throw new ServerError('You are not logged in.', 401);
}
const session = await Iron.unseal(token, TOKEN_SECRET, Iron.defaults);
const parsed = UserSessionSchema.safeParse(session);
if (!parsed.success) {
throw new ServerError('Session is invalid.', 401);
}
const { createdAt, maxAge } = parsed.data;
const expiresAt = createdAt + maxAge * 1000;
if (Date.now() > expiresAt) {
throw new ServerError('Session expired', 401);
}
return parsed.data;
}

25
config/auth/types.ts Normal file
View File

@@ -0,0 +1,25 @@
import { IncomingMessage } from 'http';
import { NextApiRequest } from 'next';
import { z } from 'zod';
export const UserInfoSchema = z.object({
id: z.string().uuid(),
username: z.string(),
});
export const UserSessionSchema = UserInfoSchema.merge(
z.object({
createdAt: z.number(),
maxAge: z.number(),
}),
);
export interface ExtendedNextApiRequest extends NextApiRequest {
user?: z.infer<typeof UserInfoSchema>;
}
export type SessionRequest = IncomingMessage & {
cookies: Partial<{
[key: string]: string;
}>;
};

View File

@@ -0,0 +1,24 @@
import { GetServerSideProps, GetServerSidePropsContext } from 'next';
import { getLoginSession } from './session';
const withPageAuthRequired =
(fn?: GetServerSideProps) => async (context: GetServerSidePropsContext) => {
try {
const { req } = context;
await getLoginSession(req);
if (!fn) {
return { props: {} };
}
return await fn(context);
} catch (error) {
return {
redirect: {
destination: '/login',
permanent: false,
},
};
}
};
export default withPageAuthRequired;

View File

@@ -0,0 +1,31 @@
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiRequest, NextApiResponse } from 'next';
import { Options } from 'next-connect';
import { z } from 'zod';
import logger from '../pino/logger';
import ServerError from '../util/ServerError';
const NextConnectConfig: Options<
NextApiRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
> = {
onNoMatch(req, res) {
res.status(405).json({
message: 'Method not allowed.',
statusCode: 405,
success: false,
});
},
onError(error, req, res) {
logger.error(error);
const message = error instanceof Error ? error.message : 'Internal server error.';
const statusCode = error instanceof ServerError ? error.statusCode : 500;
res.status(statusCode).json({
message,
statusCode,
success: false,
});
},
};
export default NextConnectConfig;