From 2ff39613cdb6bb64dbbb86fbf35ebaafbd9d2017 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Tue, 5 Dec 2023 22:36:53 -0500 Subject: [PATCH] begin extracting user controllers out of routes --- src/config/jwt/index.ts | 2 +- src/controllers/auth/index.ts | 167 ++++++++++++++++++ src/controllers/auth/types/index.ts | 17 ++ src/pages/api/users/confirm.ts | 39 +--- src/pages/api/users/forgot-password.ts | 61 +------ src/pages/api/users/login.ts | 36 +--- src/pages/api/users/logout.ts | 16 +- src/pages/api/users/register.ts | 59 +------ .../User/schema/TokenValidationSchema.ts | 7 + src/services/User/sendResetPasswordEmail.ts | 30 ++++ 10 files changed, 246 insertions(+), 188 deletions(-) create mode 100644 src/controllers/auth/index.ts create mode 100644 src/controllers/auth/types/index.ts create mode 100644 src/services/User/schema/TokenValidationSchema.ts create mode 100644 src/services/User/sendResetPasswordEmail.ts diff --git a/src/config/jwt/index.ts b/src/config/jwt/index.ts index d7d7aad..ba60a70 100644 --- a/src/config/jwt/index.ts +++ b/src/config/jwt/index.ts @@ -15,7 +15,7 @@ export const verifyConfirmationToken = async (token: string) => { const parsed = BasicUserInfoSchema.safeParse(decoded); if (!parsed.success) { - throw new Error('Invalid token'); + throw new ServerError('Invalid token.', 401); } return parsed.data; diff --git a/src/controllers/auth/index.ts b/src/controllers/auth/index.ts new file mode 100644 index 0000000..8008a75 --- /dev/null +++ b/src/controllers/auth/index.ts @@ -0,0 +1,167 @@ +import { removeTokenCookie } from '@/config/auth/cookie'; +import localStrat from '@/config/auth/localStrat'; +import { getLoginSession, setLoginSession } from '@/config/auth/session'; +import { UserExtendedNextApiRequest } from '@/config/auth/types'; +import ServerError from '@/config/util/ServerError'; +import createNewUser from '@/services/User/createNewUser'; +import findUserByEmail from '@/services/User/findUserByEmail'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { expressWrapper } from 'next-connect'; +import passport from 'passport'; +import { z } from 'zod'; + +import findUserByUsername from '@/services/User/findUserByUsername'; +import GetUserSchema from '@/services/User/schema/GetUserSchema'; +import sendConfirmationEmail from '@/services/User/sendConfirmationEmail'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import type { NextFunction } from 'express'; +import { verifyConfirmationToken } from '@/config/jwt'; +import updateUserToBeConfirmedById from '@/services/User/updateUserToBeConfirmedById'; +import DBClient from '@/prisma/DBClient'; +import sendResetPasswordEmail from '@/services/User/sendResetPasswordEmail'; +import { + RegisterUserRequest, + ResetPasswordRequest, + TokenValidationRequest, +} from './types'; + +export const authenticateUser = expressWrapper( + async ( + req: UserExtendedNextApiRequest, + res: NextApiResponse>, + next: NextFunction, + ) => { + passport.initialize(); + passport.use(localStrat); + passport.authenticate( + 'local', + { session: false }, + (error: unknown, token: z.infer) => { + if (error) { + next(error); + return; + } + req.user = token; + next(); + }, + )(req, res, next); + }, +); + +export const loginUser = async ( + req: UserExtendedNextApiRequest, + res: NextApiResponse>, +) => { + const user = req.user!; + await setLoginSession(res, user); + + res.status(200).json({ + message: 'Login successful.', + payload: user, + statusCode: 200, + success: true, + }); +}; + +export const logoutUser = async ( + req: NextApiRequest, + res: NextApiResponse>, +) => { + const session = await getLoginSession(req); + + if (!session) { + throw new ServerError('You are not logged in.', 400); + } + + removeTokenCookie(res); + + res.redirect('/'); +}; + +export const registerUser = async ( + req: RegisterUserRequest, + res: NextApiResponse>, +) => { + const [usernameTaken, emailTaken] = await Promise.all([ + findUserByUsername(req.body.username), + findUserByEmail(req.body.email), + ]); + + if (usernameTaken) { + throw new ServerError( + 'Could not register a user with that username as it is already taken.', + 409, + ); + } + + if (emailTaken) { + throw new ServerError( + 'Could not register a user with that email as it is already taken.', + 409, + ); + } + + const user = await createNewUser(req.body); + + await setLoginSession(res, { + id: user.id, + username: user.username, + }); + + await sendConfirmationEmail(user); + + res.status(201).json({ + success: true, + statusCode: 201, + message: 'User registered successfully.', + payload: user, + }); +}; + +export const confirmUser = async ( + req: TokenValidationRequest, + res: NextApiResponse>, +) => { + const { token } = req.query; + + const user = req.user!; + const { id } = await verifyConfirmationToken(token); + + if (user.accountIsVerified) { + throw new ServerError('Your account is already verified.', 400); + } + + if (user.id !== id) { + throw new ServerError('Could not confirm user.', 401); + } + + await updateUserToBeConfirmedById(id); + + res.status(200).json({ + message: 'User confirmed successfully.', + statusCode: 200, + success: true, + }); +}; + +export const resetPassword = async ( + req: ResetPasswordRequest, + res: NextApiResponse>, +) => { + const { email } = req.body; + + const user = await DBClient.instance.user.findUnique({ + where: { email }, + }); + + if (user) { + await sendResetPasswordEmail(user); + } + + res.status(200).json({ + statusCode: 200, + success: true, + message: + 'If an account with that email exists, we have sent you an email to reset your password.', + }); +}; diff --git a/src/controllers/auth/types/index.ts b/src/controllers/auth/types/index.ts new file mode 100644 index 0000000..b11984f --- /dev/null +++ b/src/controllers/auth/types/index.ts @@ -0,0 +1,17 @@ +import { UserExtendedNextApiRequest } from '@/config/auth/types'; +import { CreateUserValidationSchema } from '@/services/User/schema/CreateUserValidationSchemas'; +import TokenValidationSchema from '@/services/User/schema/TokenValidationSchema'; +import { NextApiRequest } from 'next'; +import { z } from 'zod'; + +export interface RegisterUserRequest extends NextApiRequest { + body: z.infer; +} + +export interface TokenValidationRequest extends UserExtendedNextApiRequest { + query: z.infer; +} + +export interface ResetPasswordRequest extends NextApiRequest { + body: { email: string }; +} diff --git a/src/pages/api/users/confirm.ts b/src/pages/api/users/confirm.ts index 9186631..165a41b 100644 --- a/src/pages/api/users/confirm.ts +++ b/src/pages/api/users/confirm.ts @@ -1,53 +1,24 @@ -import { UserExtendedNextApiRequest } from '@/config/auth/types'; -import { verifyConfirmationToken } from '@/config/jwt'; import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; -import ServerError from '@/config/util/ServerError'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import { NextApiResponse } from 'next'; import { createRouter } from 'next-connect'; import { z } from 'zod'; import validateRequest from '@/config/nextConnect/middleware/validateRequest'; -import updateUserToBeConfirmedById from '@/services/User/updateUserToBeConfirmedById'; -const ConfirmUserValidationSchema = z.object({ token: z.string() }); - -interface ConfirmUserRequest extends UserExtendedNextApiRequest { - query: z.infer; -} - -const confirmUser = async (req: ConfirmUserRequest, res: NextApiResponse) => { - const { token } = req.query; - - const user = req.user!; - const { id } = await verifyConfirmationToken(token); - - if (user.accountIsVerified) { - throw new ServerError('Your account is already verified.', 400); - } - - if (user.id !== id) { - throw new ServerError('Could not confirm user.', 401); - } - - await updateUserToBeConfirmedById(id); - - res.status(200).json({ - message: 'User confirmed successfully.', - statusCode: 200, - success: true, - }); -}; +import { TokenValidationRequest } from '@/controllers/auth/types'; +import { confirmUser } from '@/controllers/auth'; +import TokenValidationSchema from '@/services/User/schema/TokenValidationSchema'; const router = createRouter< - ConfirmUserRequest, + TokenValidationRequest, NextApiResponse> >(); router.get( getCurrentUser, - validateRequest({ querySchema: ConfirmUserValidationSchema }), + validateRequest({ querySchema: TokenValidationSchema }), confirmUser, ); diff --git a/src/pages/api/users/forgot-password.ts b/src/pages/api/users/forgot-password.ts index 156a5ec..e892acc 100644 --- a/src/pages/api/users/forgot-password.ts +++ b/src/pages/api/users/forgot-password.ts @@ -1,65 +1,12 @@ -import { generateResetPasswordToken } from '@/config/jwt'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; -import sendEmail from '@/config/sparkpost/sendEmail'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; -import { NextApiRequest, NextApiResponse } from 'next'; +import { NextApiResponse } from 'next'; import { createRouter } from 'next-connect'; import { z } from 'zod'; -import DBClient from '@/prisma/DBClient'; -import { render } from '@react-email/render'; -import ForgotEmail from '@/emails/ForgotEmail'; -import { ReactElement } from 'react'; -import { User } from '@prisma/client'; -import { BASE_URL } from '@/config/env'; import validateRequest from '@/config/nextConnect/middleware/validateRequest'; - -interface ResetPasswordRequest extends NextApiRequest { - body: { email: string }; -} - -const sendResetPasswordEmail = async (user: User) => { - const token = generateResetPasswordToken({ id: user.id, username: user.username }); - - const url = `${BASE_URL}/users/reset-password?token=${token}`; - - const component = ForgotEmail({ name: user.username, url })! as ReactElement< - unknown, - string - >; - - const html = render(component); - const text = render(component, { plainText: true }); - - await sendEmail({ - address: user.email, - subject: 'Reset Password', - html, - text, - }); -}; - -const forgetPassword = async ( - req: ResetPasswordRequest, - res: NextApiResponse>, -) => { - const { email } = req.body; - - const user = await DBClient.instance.user.findUnique({ - where: { email }, - }); - - if (user) { - await sendResetPasswordEmail(user); - } - - res.status(200).json({ - statusCode: 200, - success: true, - message: - 'If an account with that email exists, we have sent you an email to reset your password.', - }); -}; +import { resetPassword } from '@/controllers/auth'; +import { ResetPasswordRequest } from '@/controllers/auth/types'; const router = createRouter< ResetPasswordRequest, @@ -68,7 +15,7 @@ const router = createRouter< router.post( validateRequest({ bodySchema: z.object({ email: z.string().email() }) }), - forgetPassword, + resetPassword, ); const handler = router.handler(NextConnectOptions); diff --git a/src/pages/api/users/login.ts b/src/pages/api/users/login.ts index 525e337..5d9abbc 100644 --- a/src/pages/api/users/login.ts +++ b/src/pages/api/users/login.ts @@ -1,15 +1,12 @@ import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; -import passport from 'passport'; -import { createRouter, expressWrapper } from 'next-connect'; -import localStrat from '@/config/auth/localStrat'; -import { setLoginSession } from '@/config/auth/session'; +import { createRouter } from 'next-connect'; import { NextApiResponse } from 'next'; import { z } from 'zod'; import LoginValidationSchema from '@/services/User/schema/LoginValidationSchema'; import { UserExtendedNextApiRequest } from '@/config/auth/types'; import validateRequest from '@/config/nextConnect/middleware/validateRequest'; -import GetUserSchema from '@/services/User/schema/GetUserSchema'; +import { authenticateUser, loginUser } from '@/controllers/auth'; const router = createRouter< UserExtendedNextApiRequest, @@ -18,33 +15,8 @@ const router = createRouter< router.post( validateRequest({ bodySchema: LoginValidationSchema }), - expressWrapper(async (req, res, next) => { - passport.initialize(); - passport.use(localStrat); - passport.authenticate( - 'local', - { session: false }, - (error: unknown, token: z.infer) => { - if (error) { - next(error); - return; - } - req.user = token; - next(); - }, - )(req, res, next); - }), - async (req, res) => { - const user = req.user!; - await setLoginSession(res, user); - - res.status(200).json({ - message: 'Login successful.', - payload: user, - statusCode: 200, - success: true, - }); - }, + authenticateUser, + loginUser, ); const handler = router.handler(NextConnectOptions); diff --git a/src/pages/api/users/logout.ts b/src/pages/api/users/logout.ts index 3b33d6e..ef198b8 100644 --- a/src/pages/api/users/logout.ts +++ b/src/pages/api/users/logout.ts @@ -1,28 +1,16 @@ -import { getLoginSession } from '@/config/auth/session'; -import { removeTokenCookie } from '@/config/auth/cookie'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import { NextApiRequest, NextApiResponse } from 'next'; import { createRouter } from 'next-connect'; import { z } from 'zod'; -import ServerError from '@/config/util/ServerError'; +import { logoutUser } from '@/controllers/auth'; const router = createRouter< NextApiRequest, NextApiResponse> >(); -router.all(async (req, res) => { - const session = await getLoginSession(req); - - if (!session) { - throw new ServerError('You are not logged in.', 400); - } - - removeTokenCookie(res); - - res.redirect('/'); -}); +router.all(logoutUser); const handler = router.handler(NextConnectOptions); export default handler; diff --git a/src/pages/api/users/register.ts b/src/pages/api/users/register.ts index 51b93a6..0960df3 100644 --- a/src/pages/api/users/register.ts +++ b/src/pages/api/users/register.ts @@ -1,65 +1,24 @@ -import { setLoginSession } from '@/config/auth/session'; -import { NextApiRequest, NextApiResponse } from 'next'; +import { NextApiResponse } from 'next'; import { z } from 'zod'; -import ServerError from '@/config/util/ServerError'; import { createRouter } from 'next-connect'; -import createNewUser from '@/services/User/createNewUser'; import { CreateUserValidationSchema } from '@/services/User/schema/CreateUserValidationSchemas'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; -import findUserByUsername from '@/services/User/findUserByUsername'; -import findUserByEmail from '@/services/User/findUserByEmail'; import validateRequest from '@/config/nextConnect/middleware/validateRequest'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; - -import sendConfirmationEmail from '@/services/User/sendConfirmationEmail'; - -interface RegisterUserRequest extends NextApiRequest { - body: z.infer; -} - -const registerUser = async (req: RegisterUserRequest, res: NextApiResponse) => { - const [usernameTaken, emailTaken] = await Promise.all([ - findUserByUsername(req.body.username), - findUserByEmail(req.body.email), - ]); - - if (usernameTaken) { - throw new ServerError( - 'Could not register a user with that username as it is already taken.', - 409, - ); - } - - if (emailTaken) { - throw new ServerError( - 'Could not register a user with that email as it is already taken.', - 409, - ); - } - - const user = await createNewUser(req.body); - - await setLoginSession(res, { - id: user.id, - username: user.username, - }); - - await sendConfirmationEmail(user); - - res.status(201).json({ - success: true, - statusCode: 201, - message: 'User registered successfully.', - payload: user, - }); -}; +import { registerUser } from '@/controllers/auth'; +import { RegisterUserRequest } from '@/controllers/auth/types'; const router = createRouter< RegisterUserRequest, NextApiResponse> >(); -router.post(validateRequest({ bodySchema: CreateUserValidationSchema }), registerUser); +router.post( + validateRequest({ + bodySchema: CreateUserValidationSchema, + }), + registerUser, +); const handler = router.handler(NextConnectOptions); export default handler; diff --git a/src/services/User/schema/TokenValidationSchema.ts b/src/services/User/schema/TokenValidationSchema.ts new file mode 100644 index 0000000..3589cd9 --- /dev/null +++ b/src/services/User/schema/TokenValidationSchema.ts @@ -0,0 +1,7 @@ +import z from 'zod'; + +const TokenValidationSchema = z.object({ + token: z.string(), +}); + +export default TokenValidationSchema; diff --git a/src/services/User/sendResetPasswordEmail.ts b/src/services/User/sendResetPasswordEmail.ts new file mode 100644 index 0000000..a6410db --- /dev/null +++ b/src/services/User/sendResetPasswordEmail.ts @@ -0,0 +1,30 @@ +import { BASE_URL } from '@/config/env'; +import { generateResetPasswordToken } from '@/config/jwt'; +import sendEmail from '@/config/sparkpost/sendEmail'; +import ForgotEmail from '@/emails/ForgotEmail'; +import { User } from '@prisma/client'; +import type { ReactElement } from 'react'; +import { render } from '@react-email/render'; + +const sendResetPasswordEmail = async (user: User) => { + const token = generateResetPasswordToken({ id: user.id, username: user.username }); + + const url = `${BASE_URL}/users/reset-password?token=${token}`; + + const component = ForgotEmail({ name: user.username, url })! as ReactElement< + unknown, + string + >; + + const html = render(component); + const text = render(component, { plainText: true }); + + await sendEmail({ + address: user.email, + subject: 'Reset Password', + html, + text, + }); +}; + +export default sendResetPasswordEmail;