mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-02-16 02:39:03 +00:00
Implement authentication using Passport.js
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
37
config/auth/cookie.ts
Normal 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
24
config/auth/localStrat.ts
Normal 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;
|
||||
18
config/auth/middleware/getCurrentUser.ts
Normal file
18
config/auth/middleware/getCurrentUser.ts
Normal 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;
|
||||
6
config/auth/passwordFns.ts
Normal file
6
config/auth/passwordFns.ts
Normal 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
49
config/auth/session.ts
Normal 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
25
config/auth/types.ts
Normal 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;
|
||||
}>;
|
||||
};
|
||||
24
config/auth/withPageAuthRequired.ts
Normal file
24
config/auth/withPageAuthRequired.ts
Normal 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;
|
||||
31
config/nextConnect/NextConnectConfig.ts
Normal file
31
config/nextConnect/NextConnectConfig.ts
Normal 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
1774
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
46
package.json
46
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
24
pages/api/users/current.ts
Normal file
24
pages/api/users/current.ts
Normal 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
47
pages/api/users/login.ts
Normal 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
27
pages/api/users/logout.ts
Normal 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;
|
||||
55
pages/api/users/register.ts
Normal file
55
pages/api/users/register.ts
Normal 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;
|
||||
@@ -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
16
pages/protected.tsx
Normal 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;
|
||||
8
prisma/migrations/20230203013640_/migration.sql
Normal file
8
prisma/migrations/20230203013640_/migration.sql
Normal 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;
|
||||
12
prisma/migrations/20230206001052_/migration.sql
Normal file
12
prisma/migrations/20230206001052_/migration.sql
Normal 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");
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
49
services/user/createNewUser.ts
Normal file
49
services/user/createNewUser.ts
Normal 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;
|
||||
13
services/user/findUserByUsername.ts
Normal file
13
services/user/findUserByUsername.ts
Normal 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;
|
||||
37
services/user/schema/CreateUserValidationSchema.ts
Normal file
37
services/user/schema/CreateUserValidationSchema.ts
Normal 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;
|
||||
Reference in New Issue
Block a user