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

5
.gitignore vendored
View File

@@ -27,6 +27,7 @@ yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
@@ -34,4 +35,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.env
# http requests
*.http

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;

1774
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"name": "my-project",
"name": "biergarten",
"version": "0.1.0",
"private": true,
"scripts": {
@@ -13,42 +13,50 @@
"seed": "npx ts-node ./prisma/seed/index.ts"
},
"dependencies": {
"@hapi/iron": "7.0.0",
"@hookform/resolvers": "^2.9.10",
"@next/font": "13.1.2",
"@prisma/client": "^4.8.1",
"@next/font": "13.1.6",
"@prisma/client": "^4.9.0",
"argon2": "^0.30.3",
"cookie": "0.5.0",
"date-fns": "^2.29.3",
"next": "13.1.2",
"next": "13.1.6",
"next-connect": "^0.13.0",
"passport": "^0.6.0",
"passport-local": "^1.0.0",
"pino": "^8.8.0",
"pino-pretty": "^9.1.1",
"react": "18.2.0",
"react-daisyui": "^3.0.2",
"react-dom": "18.2.0",
"react-hook-form": "^7.42.1",
"react-hook-form": "^7.43.0",
"react-icons": "^4.7.1",
"react-rating-stars-component": "^2.2.0",
"typescript": "4.9.4",
"swr": "^2.0.3",
"zod": "^3.20.2"
},
"devDependencies": {
"@faker-js/faker": "^7.6.0",
"@types/node": "18.11.18",
"@types/react": "18.0.26",
"@types/cookie": "^0.5.1",
"@types/node": "18.11.19",
"@types/passport-local": "^1.0.35",
"@types/react": "18.0.27",
"@types/react-dom": "18.0.10",
"autoprefixer": "^10.4.13",
"daisyui": "^2.47.0",
"dotenv-cli": "^6.0.0",
"eslint": "8.32.0",
"daisyui": "^2.50.0",
"dotenv-cli": "^7.0.0",
"eslint": "8.33.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-airbnb-typescript": "17.0.0",
"eslint-config-next": "^13.0.7",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-react": "^7.31.11",
"eslint-config-next": "^13.1.6",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-react": "^7.32.2",
"postcss": "^8.4.21",
"prettier": "^2.8.1",
"prettier": "^2.8.3",
"prettier-plugin-jsdoc": "^0.4.2",
"prettier-plugin-tailwindcss": "^0.2.1",
"prisma": "^4.8.1",
"prettier-plugin-tailwindcss": "^0.2.2",
"prisma": "^4.9.0",
"tailwindcss": "^3.2.4",
"ts-node": "^10.9.1"
"ts-node": "^10.9.1",
"typescript": "^4.9.5"
}
}

View File

@@ -1,57 +1,37 @@
import NextConnectConfig from '@/config/nextConnect/NextConnectConfig';
import ServerError from '@/config/util/ServerError';
import createNewBeerComment from '@/services/BeerComment/createNewBeerComment';
import { BeerCommentQueryResultT } from '@/services/BeerComment/schema/BeerCommentQueryResult';
import BeerCommentValidationSchema from '@/services/BeerComment/schema/CreateBeerCommentValidationSchema';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiHandler } from 'next';
import nextConnect from 'next-connect';
import { z } from 'zod';
const handler: NextApiHandler<z.infer<typeof APIResponseValidationSchema>> = async (
const createComment: NextApiHandler<z.infer<typeof APIResponseValidationSchema>> = async (
req,
res,
) => {
try {
const { method } = req;
if (method !== 'POST') {
throw new ServerError('Method not allowed', 405);
}
const cleanedReqBody = BeerCommentValidationSchema.safeParse(req.body);
if (!cleanedReqBody.success) {
throw new ServerError('Invalid request body', 400);
}
const { content, rating, beerPostId } = cleanedReqBody.data;
const newBeerComment: BeerCommentQueryResultT = await createNewBeerComment({
content,
rating,
beerPostId,
});
res.status(201).json({
message: 'Beer comment created successfully',
statusCode: 201,
payload: newBeerComment,
success: true,
});
} catch (error) {
if (error instanceof ServerError) {
res.status(error.statusCode).json({
message: error.message,
statusCode: error.statusCode,
payload: null,
success: false,
});
} else {
res.status(500).json({
message: 'Internal server error',
statusCode: 500,
payload: null,
success: false,
});
}
const cleanedReqBody = BeerCommentValidationSchema.safeParse(req.body);
if (!cleanedReqBody.success) {
throw new ServerError('Invalid request body', 400);
}
const { content, rating, beerPostId } = cleanedReqBody.data;
const newBeerComment: BeerCommentQueryResultT = await createNewBeerComment({
content,
rating,
beerPostId,
});
res.status(201).json({
message: 'Beer comment created successfully',
statusCode: 201,
payload: newBeerComment,
success: true,
});
};
const handler = nextConnect(NextConnectConfig).post(createComment);
export default handler;

View File

@@ -1,59 +1,38 @@
import nextConnect from 'next-connect';
import ServerError from '@/config/util/ServerError';
import createNewBeerPost from '@/services/BeerPost/createNewBeerPost';
import BeerPostValidationSchema from '@/services/BeerPost/schema/CreateBeerPostValidationSchema';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiHandler } from 'next';
import { z } from 'zod';
import NextConnectConfig from '@/config/nextConnect/NextConnectConfig';
const handler: NextApiHandler<z.infer<typeof APIResponseValidationSchema>> = async (
req,
res,
) => {
try {
const { method } = req;
if (method !== 'POST') {
throw new ServerError('Method not allowed', 405);
}
const cleanedReqBody = BeerPostValidationSchema.safeParse(req.body);
if (!cleanedReqBody.success) {
throw new ServerError('Invalid request body', 400);
}
const { name, description, typeId, abv, ibu, breweryId } = cleanedReqBody.data;
const newBeerPost = await createNewBeerPost({
name,
description,
abv,
ibu,
typeId,
breweryId,
});
res.status(201).json({
message: 'Beer post created successfully',
statusCode: 201,
payload: newBeerPost,
success: true,
});
} catch (error) {
if (error instanceof ServerError) {
res.status(error.statusCode).json({
message: error.message,
statusCode: error.statusCode,
payload: null,
success: false,
});
} else {
res.status(500).json({
message: 'Internal server error',
statusCode: 500,
payload: null,
success: false,
});
}
const createBeerPost: NextApiHandler<
z.infer<typeof APIResponseValidationSchema>
> = async (req, res) => {
const cleanedReqBody = BeerPostValidationSchema.safeParse(req.body);
if (!cleanedReqBody.success) {
throw new ServerError('Invalid request body', 400);
}
const { name, description, typeId, abv, ibu, breweryId } = cleanedReqBody.data;
const newBeerPost = await createNewBeerPost({
name,
description,
abv,
ibu,
typeId,
breweryId,
});
res.status(201).json({
message: 'Beer post created successfully',
statusCode: 201,
payload: newBeerPost,
success: true,
});
};
const handler = nextConnect(NextConnectConfig).post(createBeerPost);
export default handler;

View File

@@ -0,0 +1,24 @@
import NextConnectConfig from '@/config/nextConnect/NextConnectConfig';
import { ExtendedNextApiRequest } from '@/config/auth/types';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import getCurrentUser from '@/config/auth/middleware/getCurrentUser';
import nextConnect from 'next-connect';
import { z } from 'zod';
const sendCurrentUser = async (req: ExtendedNextApiRequest, res: NextApiResponse) => {
const { user } = req;
res.status(200).json({
message: `Currently logged in as ${user!.username}`,
statusCode: 200,
success: true,
payload: user,
});
};
const handler = nextConnect<
ExtendedNextApiRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>(NextConnectConfig).get(getCurrentUser, sendCurrentUser);
export default handler;

47
pages/api/users/login.ts Normal file
View File

@@ -0,0 +1,47 @@
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import NextConnectConfig from '@/config/nextConnect/NextConnectConfig';
import passport from 'passport';
import nextConnect from 'next-connect';
import localStrat from '@/config/auth/localStrat';
import { setLoginSession } from '@/config/auth/session';
import { NextApiResponse } from 'next';
import { z } from 'zod';
import ServerError from '@/config/util/ServerError';
import { ExtendedNextApiRequest } from '../../../config/auth/types';
const LoginSchema = z.object({
username: z.string(),
password: z.string(),
});
export default nextConnect<
ExtendedNextApiRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>(NextConnectConfig)
.use(passport.initialize())
.use(async (req, res, next) => {
passport.use(localStrat);
const parsed = LoginSchema.safeParse(req.body);
if (!parsed.success) {
throw new ServerError('Username and password are required.', 400);
}
passport.authenticate('local', { session: false }, (error, token) => {
if (error) {
next(error);
} else {
req.user = token;
next();
}
})(req, res, next);
})
.post(async (req, res) => {
const user = req.user!;
await setLoginSession(res, user);
res.status(200).json({
message: 'Login successful.',
statusCode: 200,
success: true,
});
});

27
pages/api/users/logout.ts Normal file
View File

@@ -0,0 +1,27 @@
import { getLoginSession } from '@/config/auth/session';
import { removeTokenCookie } from '@/config/auth/cookie';
import NextConnectConfig from '@/config/nextConnect/NextConnectConfig';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiRequest, NextApiResponse } from 'next';
import nextConnect from 'next-connect';
import { z } from 'zod';
import ServerError from '@/config/util/ServerError';
const handler = nextConnect<
NextApiRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>(NextConnectConfig).all(async (req, res) => {
const session = await getLoginSession(req);
if (!session) {
throw new ServerError('You are not logged in.', 400);
}
removeTokenCookie(res);
res.status(200).json({
message: 'Logged out.',
statusCode: 200,
success: true,
});
});
export default handler;

View File

@@ -0,0 +1,55 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { z } from 'zod';
import ServerError from '@/config/util/ServerError';
import nc, { NextHandler } from 'next-connect';
import createNewUser from '@/services/user/createNewUser';
import CreateUserValidationSchema from '@/services/user/schema/CreateUserValidationSchema';
import NextConnectConfig from '@/config/nextConnect/NextConnectConfig';
interface RegisterUserRequest extends NextApiRequest {
body: z.infer<typeof CreateUserValidationSchema>;
}
const validateRequest =
({
bodySchema,
querySchema,
}: {
bodySchema?: z.ZodSchema<any>;
querySchema?: z.ZodSchema<any>;
}) =>
async (req: NextApiRequest, res: NextApiResponse, next: NextHandler) => {
if (bodySchema) {
const parsed = bodySchema.safeParse(req.body);
if (!parsed.success) {
throw new ServerError('Invalid request body.', 400);
}
}
if (querySchema) {
const parsed = querySchema.safeParse(req.query);
if (!parsed.success) {
throw new ServerError(parsed.error.message, 400);
}
req.query = parsed.data;
}
next();
};
const registerUser = async (req: RegisterUserRequest, res: NextApiResponse) => {
const user = await createNewUser(req.body);
res.status(201).json({
message: 'User created successfully.',
payload: user,
statusCode: 201,
success: true,
});
};
const handler = nc(NextConnectConfig).post(
validateRequest({ bodySchema: CreateUserValidationSchema }),
registerUser,
);
export default handler;

View File

@@ -1,5 +1,6 @@
import BeerForm from '@/components/BeerForm';
import Layout from '@/components/ui/Layout';
import DBClient from '@/prisma/DBClient';
import getAllBreweryPosts from '@/services/BreweryPost/getAllBreweryPosts';
import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult';

16
pages/protected.tsx Normal file
View File

@@ -0,0 +1,16 @@
import withPageAuthRequired from '@/config/auth/withPageAuthRequired';
import { GetServerSideProps, NextPage } from 'next';
const protectedPage: NextPage<{
username: string;
}> = ({ username }) => {
return (
<div>
<h1> Hello, {username}! </h1>
</div>
);
};
export const getServerSideProps: GetServerSideProps = withPageAuthRequired();
export default protectedPage;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `hash` to the `User` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "User" ADD COLUMN "hash" TEXT NOT NULL;

View File

@@ -0,0 +1,12 @@
/*
Warnings:
- A unique constraint covering the columns `[username]` on the table `User` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[email]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");

View File

@@ -12,10 +12,11 @@ datasource db {
model User {
id String @id @default(uuid())
username String
username String @unique
firstName String
lastName String
email String
hash String
email String @unique
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime? @updatedAt @db.Timestamptz(3)
dateOfBirth DateTime

View File

@@ -1,3 +1,4 @@
import argon2 from 'argon2';
// eslint-disable-next-line import/no-extraneous-dependencies
import { faker } from '@faker-js/faker';
import DBClient from '../../DBClient';
@@ -9,12 +10,19 @@ interface CreateNewUsersArgs {
const createNewUsers = async ({ numberOfUsers }: CreateNewUsersArgs) => {
const prisma = DBClient.instance;
const userPromises = [];
const hashedPasswords = await Promise.all(
Array.from({ length: numberOfUsers }, () => argon2.hash(faker.internet.password())),
);
// eslint-disable-next-line no-plusplus
for (let i = 0; i < numberOfUsers; i++) {
const firstName = faker.name.firstName();
const lastName = faker.name.lastName();
const username = `${firstName[0]}.${lastName}`;
const email = faker.internet.email(firstName, lastName, 'example.com');
const username = `${firstName[0]}.${lastName}.${i}`;
const email = faker.internet.email(firstName, lastName + i, 'example.com');
const hash = hashedPasswords[i];
const dateOfBirth = faker.date.birthdate({ mode: 'age', min: 19 });
const createdAt = faker.date.past(1);
userPromises.push(
@@ -26,6 +34,7 @@ const createNewUsers = async ({ numberOfUsers }: CreateNewUsersArgs) => {
username,
dateOfBirth,
createdAt,
hash,
},
}),
);

View File

@@ -0,0 +1,49 @@
import findUserByUsername from '@/services/user/findUserByUsername';
import { hashPassword } from '@/config/auth/passwordFns';
import DBClient from '@/prisma/DBClient';
import { z } from 'zod';
import ServerError from '@/config/util/ServerError';
import CreateUserValidationSchema from './schema/CreateUserValidationSchema';
const createNewUser = async ({
email,
password,
firstName,
lastName,
dateOfBirth,
username,
}: z.infer<typeof CreateUserValidationSchema>) => {
const userExists = await findUserByUsername(username);
if (userExists) {
throw new ServerError(
"Could not register a user with that username as it's already taken.",
409,
);
}
const hash = await hashPassword(password);
const user = DBClient.instance.user.create({
data: {
username,
email,
hash,
firstName,
lastName,
dateOfBirth: new Date(dateOfBirth),
},
select: {
id: true,
username: true,
email: true,
firstName: true,
lastName: true,
dateOfBirth: true,
createdAt: true,
},
});
return user;
};
export default createNewUser;

View File

@@ -0,0 +1,13 @@
import DBClient from '@/prisma/DBClient';
const findUserByUsername = async (username: string) =>
DBClient.instance.user.findFirst({
where: { username },
select: {
id: true,
username: true,
hash: true,
},
});
export default findUserByUsername;

View File

@@ -0,0 +1,37 @@
import sub from 'date-fns/sub';
import { z } from 'zod';
const minimumDateOfBirth = sub(new Date(), { years: 19 });
const CreateUserValidationSchema = z.object({
email: z.string().email({ message: 'Email must be a valid email address.' }),
// use special characters, numbers, and uppercase letters
password: z
.string()
.min(8, { message: 'Password must be at least 8 characters.' })
.refine((password) => /[A-Z]/.test(password), {
message: 'Password must contain at least one uppercase letter.',
})
.refine((password) => /[0-9]/.test(password), {
message: 'Password must contain at least one number.',
})
.refine((password) => /[^a-zA-Z0-9]/.test(password), {
message: 'Password must contain at least one special character.',
}),
firstName: z.string().min(1, { message: 'First name must not be empty.' }),
lastName: z.string().min(1, { message: 'Last name must not be empty.' }),
username: z
.string()
.min(1, { message: 'Username must not be empty.' })
.max(20, { message: 'Username must be less than 20 characters.' }),
dateOfBirth: z.string().refine(
(dateOfBirth) => {
const parsedDateOfBirth = new Date(dateOfBirth);
return parsedDateOfBirth <= minimumDateOfBirth;
},
{ message: 'You must be at least 19 years old to register.' },
),
});
export default CreateUserValidationSchema;