mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-02-16 10:42:08 +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
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
|
.env
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@@ -34,4 +35,6 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
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",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -13,42 +13,50 @@
|
|||||||
"seed": "npx ts-node ./prisma/seed/index.ts"
|
"seed": "npx ts-node ./prisma/seed/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hapi/iron": "7.0.0",
|
||||||
"@hookform/resolvers": "^2.9.10",
|
"@hookform/resolvers": "^2.9.10",
|
||||||
"@next/font": "13.1.2",
|
"@next/font": "13.1.6",
|
||||||
"@prisma/client": "^4.8.1",
|
"@prisma/client": "^4.9.0",
|
||||||
|
"argon2": "^0.30.3",
|
||||||
|
"cookie": "0.5.0",
|
||||||
"date-fns": "^2.29.3",
|
"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": "^8.8.0",
|
||||||
"pino-pretty": "^9.1.1",
|
"pino-pretty": "^9.1.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-daisyui": "^3.0.2",
|
"react-daisyui": "^3.0.2",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.42.1",
|
"react-hook-form": "^7.43.0",
|
||||||
"react-icons": "^4.7.1",
|
"react-icons": "^4.7.1",
|
||||||
"react-rating-stars-component": "^2.2.0",
|
"swr": "^2.0.3",
|
||||||
"typescript": "4.9.4",
|
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@faker-js/faker": "^7.6.0",
|
"@faker-js/faker": "^7.6.0",
|
||||||
"@types/node": "18.11.18",
|
"@types/cookie": "^0.5.1",
|
||||||
"@types/react": "18.0.26",
|
"@types/node": "18.11.19",
|
||||||
|
"@types/passport-local": "^1.0.35",
|
||||||
|
"@types/react": "18.0.27",
|
||||||
"@types/react-dom": "18.0.10",
|
"@types/react-dom": "18.0.10",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"daisyui": "^2.47.0",
|
"daisyui": "^2.50.0",
|
||||||
"dotenv-cli": "^6.0.0",
|
"dotenv-cli": "^7.0.0",
|
||||||
"eslint": "8.32.0",
|
"eslint": "8.33.0",
|
||||||
"eslint-config-airbnb-base": "15.0.0",
|
"eslint-config-airbnb-base": "15.0.0",
|
||||||
"eslint-config-airbnb-typescript": "17.0.0",
|
"eslint-config-airbnb-typescript": "17.0.0",
|
||||||
"eslint-config-next": "^13.0.7",
|
"eslint-config-next": "^13.1.6",
|
||||||
"eslint-config-prettier": "^8.3.0",
|
"eslint-config-prettier": "^8.6.0",
|
||||||
"eslint-plugin-react": "^7.31.11",
|
"eslint-plugin-react": "^7.32.2",
|
||||||
"postcss": "^8.4.21",
|
"postcss": "^8.4.21",
|
||||||
"prettier": "^2.8.1",
|
"prettier": "^2.8.3",
|
||||||
"prettier-plugin-jsdoc": "^0.4.2",
|
"prettier-plugin-jsdoc": "^0.4.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.2.1",
|
"prettier-plugin-tailwindcss": "^0.2.2",
|
||||||
"prisma": "^4.8.1",
|
"prisma": "^4.9.0",
|
||||||
"tailwindcss": "^3.2.4",
|
"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 ServerError from '@/config/util/ServerError';
|
||||||
import createNewBeerComment from '@/services/BeerComment/createNewBeerComment';
|
import createNewBeerComment from '@/services/BeerComment/createNewBeerComment';
|
||||||
import { BeerCommentQueryResultT } from '@/services/BeerComment/schema/BeerCommentQueryResult';
|
import { BeerCommentQueryResultT } from '@/services/BeerComment/schema/BeerCommentQueryResult';
|
||||||
import BeerCommentValidationSchema from '@/services/BeerComment/schema/CreateBeerCommentValidationSchema';
|
import BeerCommentValidationSchema from '@/services/BeerComment/schema/CreateBeerCommentValidationSchema';
|
||||||
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
|
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
|
||||||
import { NextApiHandler } from 'next';
|
import { NextApiHandler } from 'next';
|
||||||
|
import nextConnect from 'next-connect';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const handler: NextApiHandler<z.infer<typeof APIResponseValidationSchema>> = async (
|
const createComment: NextApiHandler<z.infer<typeof APIResponseValidationSchema>> = async (
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
) => {
|
) => {
|
||||||
try {
|
const cleanedReqBody = BeerCommentValidationSchema.safeParse(req.body);
|
||||||
const { method } = req;
|
if (!cleanedReqBody.success) {
|
||||||
|
throw new ServerError('Invalid request body', 400);
|
||||||
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 { 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;
|
export default handler;
|
||||||
|
|||||||
@@ -1,59 +1,38 @@
|
|||||||
|
import nextConnect from 'next-connect';
|
||||||
import ServerError from '@/config/util/ServerError';
|
import ServerError from '@/config/util/ServerError';
|
||||||
import createNewBeerPost from '@/services/BeerPost/createNewBeerPost';
|
import createNewBeerPost from '@/services/BeerPost/createNewBeerPost';
|
||||||
import BeerPostValidationSchema from '@/services/BeerPost/schema/CreateBeerPostValidationSchema';
|
import BeerPostValidationSchema from '@/services/BeerPost/schema/CreateBeerPostValidationSchema';
|
||||||
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
|
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
|
||||||
import { NextApiHandler } from 'next';
|
import { NextApiHandler } from 'next';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import NextConnectConfig from '@/config/nextConnect/NextConnectConfig';
|
||||||
|
|
||||||
const handler: NextApiHandler<z.infer<typeof APIResponseValidationSchema>> = async (
|
const createBeerPost: NextApiHandler<
|
||||||
req,
|
z.infer<typeof APIResponseValidationSchema>
|
||||||
res,
|
> = async (req, res) => {
|
||||||
) => {
|
const cleanedReqBody = BeerPostValidationSchema.safeParse(req.body);
|
||||||
try {
|
if (!cleanedReqBody.success) {
|
||||||
const { method } = req;
|
throw new ServerError('Invalid request body', 400);
|
||||||
|
|
||||||
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 { 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;
|
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 BeerForm from '@/components/BeerForm';
|
||||||
import Layout from '@/components/ui/Layout';
|
import Layout from '@/components/ui/Layout';
|
||||||
|
|
||||||
import DBClient from '@/prisma/DBClient';
|
import DBClient from '@/prisma/DBClient';
|
||||||
import getAllBreweryPosts from '@/services/BreweryPost/getAllBreweryPosts';
|
import getAllBreweryPosts from '@/services/BreweryPost/getAllBreweryPosts';
|
||||||
import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult';
|
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 {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
username String
|
username String @unique
|
||||||
firstName String
|
firstName String
|
||||||
lastName String
|
lastName String
|
||||||
email String
|
hash String
|
||||||
|
email String @unique
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
updatedAt DateTime? @updatedAt @db.Timestamptz(3)
|
updatedAt DateTime? @updatedAt @db.Timestamptz(3)
|
||||||
dateOfBirth DateTime
|
dateOfBirth DateTime
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import argon2 from 'argon2';
|
||||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import DBClient from '../../DBClient';
|
import DBClient from '../../DBClient';
|
||||||
@@ -9,12 +10,19 @@ interface CreateNewUsersArgs {
|
|||||||
const createNewUsers = async ({ numberOfUsers }: CreateNewUsersArgs) => {
|
const createNewUsers = async ({ numberOfUsers }: CreateNewUsersArgs) => {
|
||||||
const prisma = DBClient.instance;
|
const prisma = DBClient.instance;
|
||||||
const userPromises = [];
|
const userPromises = [];
|
||||||
|
|
||||||
|
const hashedPasswords = await Promise.all(
|
||||||
|
Array.from({ length: numberOfUsers }, () => argon2.hash(faker.internet.password())),
|
||||||
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line no-plusplus
|
// eslint-disable-next-line no-plusplus
|
||||||
for (let i = 0; i < numberOfUsers; i++) {
|
for (let i = 0; i < numberOfUsers; i++) {
|
||||||
const firstName = faker.name.firstName();
|
const firstName = faker.name.firstName();
|
||||||
const lastName = faker.name.lastName();
|
const lastName = faker.name.lastName();
|
||||||
const username = `${firstName[0]}.${lastName}`;
|
const username = `${firstName[0]}.${lastName}.${i}`;
|
||||||
const email = faker.internet.email(firstName, lastName, 'example.com');
|
const email = faker.internet.email(firstName, lastName + i, 'example.com');
|
||||||
|
|
||||||
|
const hash = hashedPasswords[i];
|
||||||
const dateOfBirth = faker.date.birthdate({ mode: 'age', min: 19 });
|
const dateOfBirth = faker.date.birthdate({ mode: 'age', min: 19 });
|
||||||
const createdAt = faker.date.past(1);
|
const createdAt = faker.date.past(1);
|
||||||
userPromises.push(
|
userPromises.push(
|
||||||
@@ -26,6 +34,7 @@ const createNewUsers = async ({ numberOfUsers }: CreateNewUsersArgs) => {
|
|||||||
username,
|
username,
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
createdAt,
|
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