From 87c1904d991eaae649303da7be0d839753f18625 Mon Sep 17 00:00:00 2001 From: Chinedu Ekene Okpala Date: Mon, 5 Aug 2024 08:34:19 +0000 Subject: [PATCH] feat(Authentication---Login-Implementation,-Caching-Implementation,-some-code-refactoring): Apply auth guard to controller. Cache service methods response to redis, refactor utility functions BREAKING CHANGE: --- src/controllers/auth.controller.ts | 38 +++++++++-- src/controllers/user.controller.ts | 63 ++++++++++------- src/decorators/validator.ts | 21 ++++-- src/di/modules/repositories.module.ts | 5 +- src/repositories/base.repository.ts | 24 +++---- src/services/auth.service.ts | 80 +++++++++++----------- src/services/user.service.ts | 97 ++++++++++++++++++--------- src/utils/appError.ts | 7 +- src/utils/helper.ts | 29 ++++++++ src/utils/index.ts | 1 + src/validators/index.ts | 14 +++- src/validators/user.zod.schema.ts | 8 +-- 12 files changed, 253 insertions(+), 134 deletions(-) diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index fc480ca..d1006fd 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -11,6 +11,7 @@ import { ResetPasswordSchema, } from 'validators'; import { Route, Validator, Controller } from 'decorators'; +import { cookiesConfig, isProd } from 'configs/env.config'; @Controller('/auth') @injectable() @@ -22,7 +23,7 @@ export class AuthController { @Route('post', '/signup') @Validator({ body: SignupSchema }) - async signup(req: Request, res: Response) { + async signup(req: Request<[], [], SignupSchema>, res: Response) { return res.status(CREATED).json({ user: await this.service.register(req.body), message: 'User created successfully', @@ -31,13 +32,37 @@ export class AuthController { @Route('post', '/login') @Validator({ body: LoginSchema }) - async login(req: Request, res: Response) { - return res.status(OK).json(await this.service.authenticate(req.body)); + async login(req: Request<[], [], LoginSchema>, res: Response) { + const result = await this.service.authenticate(req.body); + if (result) { + // set session + req.session.user = result.user; + req.session.authorized = true; + return res + .cookie('auth_token', result.token, { + httpOnly: true, + maxAge: +cookiesConfig.maxAge, + secure: isProd, + }) + .status(OK) + .json({ ...result.user, token: result.token }); + } + } + + @Route('post', '/logout') + async logout(req: Request, res: Response) { + return req.session.destroy(function () { + // Clear the session cookie + return res + .clearCookie('auth_token') + .status(OK) + .json({ message: 'Successfully logged out 😏 🍀' }); + }); } @Route('post', '/request-password-reset') @Validator({ body: EmailSchema }) - async requestPasswordReset(req: Request, res: Response) { + async requestPasswordReset(req: Request<[], [], EmailSchema>, res: Response) { return res .status(OK) .json(await this.service.sendPasswordResetEmail(req.body)); @@ -45,7 +70,10 @@ export class AuthController { @Route('post', '/password-reset') @Validator({ body: ResetPasswordSchema }) - async passwordReset(req: Request, res: Response) { + async passwordReset( + req: Request<[], [], ResetPasswordSchema>, + res: Response, + ) { return res.status(OK).json(await this.service.resetPassword(req.body)); } } diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index b7d3e57..0ea6093 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -1,11 +1,17 @@ -import { OK } from 'http-status'; import { Request, Response } from 'express'; import { injectable, inject } from 'inversify'; +import { ACCEPTED, NO_CONTENT, OK } from 'http-status'; +import { + QuerySchema, + ParamsWithId, + UpdateSchema, + DeleteTypeSchema, +} from 'validators'; import { TYPES } from 'di/types'; +import { auth } from 'middlewares'; import { IUserService } from 'services'; import { Route, Controller, Validator } from 'decorators'; -import { miscSchema, UpdateSchema, QuerySchema } from 'validators'; @Controller('/users') @injectable() @@ -15,41 +21,48 @@ export class UserController { private service: IUserService, ) {} - @Route('get') + @Route('get', '', auth) async getAll(_: Request, res: Response) { return res.status(OK).json(await this.service.getAllUsers()); } - @Route('get', '/:id') - @Validator({ params: miscSchema('id') }) - async getById(req: Request, res: Response) { - return res.status(OK).json(await this.service.getUserById(req.params.id)); + @Route('get', '/search', auth) + @Validator({ query: QuerySchema }) + async query(req: Request<[], [], [], QuerySchema>, res: Response) { + return res.status(OK).json(await this.service.getUsersByQuery(req.query)); } - @Route('get', '/search') - @Validator({ query: QuerySchema }) - async query(req: Request, res: Response) { - return res - .status(OK) - .json(await this.service.getUsersBasedOnQuery(req.query)); + @Route('get', '/:id', auth) + @Validator({ params: ParamsWithId }) + async getById(req: Request, res: Response) { + return res.status(OK).json(await this.service.getUserById(req.params.id)); } - @Route('patch') - @Validator({ body: UpdateSchema, params: miscSchema('id') }) - async update(req: Request, res: Response) { + @Route('patch', '/:id', auth) + @Validator({ body: UpdateSchema, params: ParamsWithId }) + async update(req: Request, res: Response) { return res.status(OK).json({ message: 'User details updated successfully', - data: await this.service.updateUser(req.params.id, req.body), + data: await this.service.update(req.params.id, req.body), }); } - @Route('delete', '/:id') - @Validator({ params: miscSchema('id') }) - async delete(req: Request, res: Response) { - await this.service.softDeleteUserById(req.params.id); - return res.status(OK).json({ - message: - 'Account deleted succesfully. Account will be parmantly deleted in 30day', - }); + @Route('delete', '/:id', auth) + @Validator({ params: ParamsWithId, body: DeleteTypeSchema }) + async delete( + req: Request, + res: Response, + ) { + if (req.body.type === 'soft') { + await this.service.softDeleteById(req.params.id); + return res.status(ACCEPTED).json({ + message: 'Account will be parmantly deleted in 30day', + }); + } else { + await this.service.forceDeleteById(req.params.id); + return res.status(NO_CONTENT).json({ + message: 'Account deleted succesfully.', + }); + } } } diff --git a/src/decorators/validator.ts b/src/decorators/validator.ts index e6922e7..bc78a31 100644 --- a/src/decorators/validator.ts +++ b/src/decorators/validator.ts @@ -25,17 +25,24 @@ interface ParamsOk extends BaseSchema { type SchemaPayload = BodyOk | QueryOk | ParamsOk; -const errorMessageBuilder = (issue: ZodIssue) => { - let str = ''; - if (issue.message.toLowerCase().includes('invalid')) { - str = `${issue.path.join('.')} contains ${issue.message?.toLowerCase()}`; +const errorMessageBuilder = ( + issue: ZodIssue, +): string | Record => { + const message = issue.message.toLowerCase(); + + if (message.includes('one of')) { + return { message: issue.message, path: issue.path }; + } + + if (message.includes('invalid')) { + return `${issue.path} contains ${message}`; } - if (issue.message.toLowerCase().includes('required')) { - str = `${issue.path.join('.')} is ${issue.message?.toLowerCase()}`; + if (message.includes('required')) { + return `${issue.path} is ${message}`; } - return str; + return ''; }; export function Validator({ body, query, params }: SchemaPayload) { diff --git a/src/di/modules/repositories.module.ts b/src/di/modules/repositories.module.ts index 4663c38..b699a39 100644 --- a/src/di/modules/repositories.module.ts +++ b/src/di/modules/repositories.module.ts @@ -4,10 +4,7 @@ import { UserRepository } from 'repositories'; import { TYPES } from 'di/types'; const initializeModule = (bind: interfaces.Bind) => { - /* bind>(TYPES.UserRepository).toConstantValue( - new UserRepository(new UserModel()), - ); */ bind(TYPES.UserRepository).to(UserRepository); }; -export const RepositoryModule = new ContainerModule(initializeModule); +export const RepositoriesModule = new ContainerModule(initializeModule); diff --git a/src/repositories/base.repository.ts b/src/repositories/base.repository.ts index e343caa..8d7a84b 100644 --- a/src/repositories/base.repository.ts +++ b/src/repositories/base.repository.ts @@ -18,8 +18,8 @@ export class BaseRepository { return await this.model.create(payload); } - public async getAll() { - return await this.model.findAll(); + public async getAll(query: WhereOptions> = {}) { + return await this.model.findAll({ where: query }); } public async getById(id: string) { @@ -30,18 +30,16 @@ export class BaseRepository { return await this.model.findOne({ where: query }); } - public async query(query: WhereOptions>) { - return await this.model.findAll({ where: query }); - } - - public async update( - id: WhereAttributeHashValue[string]>, - payload: Partial, - ) { - return await this.model.update(payload, { where: { id: id } }); + public async updateById(id: string, payload: Partial) { + return await this.model.update(payload, { + where: { id: id as WhereAttributeHashValue[string]> }, + }); } - public async delete(id: WhereAttributeHashValue[string]>) { - return await this.model.destroy({ where: { id } }); + public async deleteById(id: string, forceDel?: boolean) { + return await this.model.destroy({ + where: { id: id as WhereAttributeHashValue[string]> }, + force: forceDel, + }); } } diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 3e22d51..796a588 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -1,4 +1,3 @@ -import bcrypt from 'bcrypt'; import { CONFLICT, BAD_REQUEST, @@ -6,12 +5,15 @@ import { INTERNAL_SERVER_ERROR, } from 'http-status'; import { injectable, inject } from 'inversify'; +import { CacheUpdate } from '@type-cacheable/core'; +import { useAdapter } from '@type-cacheable/ioredis-adapter'; import { TYPES } from 'di/types'; -import { AppError } from 'utils'; import { UserModelDto } from 'db/models'; -import { HASHING_SALT } from 'configs/env'; +import { AppError, exclude } from 'utils'; +import { BaseService } from './base.service'; import { UserRepository } from 'repositories'; +import { RedisClient } from 'configs/redis.config'; import type { EmailSchema, LoginSchema, @@ -19,8 +21,6 @@ import type { ResetPasswordSchema, } from 'validators'; -import { BaseService } from './base.service'; - export interface IAuthService { register(dto: SignupSchema): Promise< | AppError @@ -29,7 +29,9 @@ export interface IAuthService { email: string; } >; - authenticate(dto: LoginSchema): Promise; + authenticate( + dto: LoginSchema, + ): Promise<{ user: UserModelDto; token: string }>; sendPasswordResetEmail(payload: EmailSchema): Promise<{ message: string }>; resetPassword(payload: ResetPasswordSchema): Promise<{ message: string }>; } @@ -39,58 +41,57 @@ export class AuthService extends BaseService implements IAuthService { constructor( @inject(TYPES.UserRepository) private repo: UserRepository, + @inject(TYPES.RedisClient) + redisClient: RedisClient, ) { super(); + useAdapter( + redisClient.get({ + enableOfflineQueue: true, + }), + false, + ); + this.on('user_login', async () => {}); this.on('new_sign_up', async () => {}); this.on('user_failed_login', async () => {}); } - private async hashPassword(password: string) { - return await bcrypt.hash(password, HASHING_SALT); - } - - private async isPasswordMatch(p1: string, p2: string) { - return await bcrypt.compare(p1, p2); - } - - // private generateAuthToken(u: UserModelDto) {} - private async validateJWT(token: string) { if (token) return token; return null; } + @CacheUpdate({ + cacheKey: (_, __, result) => result.id, + cacheKeysToClear: ['users'], + }) public async register(dto: SignupSchema) { - const isEmailTaken = await this.repo.getOne({ email: dto.email }); - if (isEmailTaken) { - return new AppError('A user with this email already exist', CONFLICT); + let user = await this.repo.getOne({ email: dto.email }); + if (user) { + throw new AppError('A user with this email already exist', CONFLICT); } - - const password = await this.hashPassword(dto.password); - - const user = await this.repo.create({ ...dto, password }); - - return { name: `${user.firstName} ${user.lastName}`, email: user.email }; + user = await this.repo.create(dto); + return { name: user.getFullname(), email: user.email }; } public async authenticate(dto: LoginSchema) { const user = await this.repo.getOne({ email: dto.email }); - if (user) { - if (await this.isPasswordMatch(dto.password, user.password)) { - /* const token = this.tokenService.create(user); - const userDto = this.modelToDto(user); - return { - tokenInfo: token, - user: userDto, - }; */ - - return user; - } + if (user && (await user.isPasswordMatch(dto.password))) { + const token = user.generateAuthToken('auth'); + return { + user: exclude(user.toJSON(), [ + 'password', + 'updatedAt', + 'createdAt', + 'deletedAt', + ]), + token, + }; } - new AppError('Invalid email or password', BAD_REQUEST); + throw new AppError('Invalid email or password', BAD_REQUEST); } public async sendPasswordResetEmail(payload: EmailSchema) { @@ -107,8 +108,9 @@ export class AuthService extends BaseService implements IAuthService { public async resetPassword(payload: ResetPasswordSchema) { const id = await this.validateJWT(payload.token); if (id) { - const password = await this.hashPassword(payload.newPassword); - const [no] = await this.repo.update(id, { password }); + const [no] = await this.repo.updateById(id, { + password: payload.newPassword, + }); if (no) { return { message: 'Your password has been succesfully updated.' }; } diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 5d9dcf0..9830f38 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -1,24 +1,26 @@ -import { BAD_REQUEST, NOT_FOUND } from 'http-status'; +import qs from 'node:querystring'; import { injectable, inject } from 'inversify'; +import { BAD_REQUEST, NOT_FOUND } from 'http-status'; +import { useAdapter } from '@type-cacheable/ioredis-adapter'; +import { Cacheable, CacheClear, CacheUpdate } from '@type-cacheable/core'; import { TYPES } from 'di/types'; import { UserModel, UserModelDto } from 'db/models'; -import { UserRepository } from 'repositories'; +import { AppError, exclude } from 'utils'; import { BaseService } from './base.service'; -import { AppError } from 'utils'; +import { UserRepository } from 'repositories'; +import { RedisClient } from 'configs/redis.config'; import { type UpdateSchema, type QuerySchema } from 'validators'; export interface IUserService { - getAllUsers(): Promise<{ data: UserModel[]; message: string }>; - getUserById(id: string): Promise; - getOneUser( - query: Pick, - ): Promise; - getUsersBasedOnQuery( + getAllUsers(): Promise<{ data: UserModelDto[]; message: string }>; + getUserById(id: string): Promise; + getUsersByQuery( query: QuerySchema, - ): Promise<{ data: UserModel[]; message: string }>; - updateUser(id: string, payload: UpdateSchema): Promise; - softDeleteUserById(id: string): Promise; + ): Promise<{ data: UserModelDto[]; message: string }>; + update(id: string, payload: UpdateSchema): Promise; + softDeleteById(id: string): Promise; + forceDeleteById(id: string): Promise; } @injectable() @@ -26,49 +28,82 @@ export class UserService extends BaseService implements IUserService { constructor( @inject(TYPES.UserRepository) protected repo: UserRepository, + @inject(TYPES.RedisClient) + redisClient: RedisClient, ) { super(); + useAdapter( + redisClient.get({ + enableOfflineQueue: true, + }), + false, + { ttlSeconds: 3600 }, + ); } + private dto(user: UserModel) { + const keys = [ + ...new Set([ + !user.dateOfBirth ? 'dateOfBirth' : 'password', + !user.deletedAt ? 'deletedAt' : 'password', + 'password', + ]), + ]; + return exclude(user.toJSON(), keys); + } + + @Cacheable({ cacheKey: 'users' }) public async getAllUsers() { const users = await this.repo.getAll(); // run some formating and all need data manipulation - return { data: users, message: `${users.length} users found.` }; + return { + data: users.map(this.dto), + message: `${users.length} user${users.length > 1 ? 's' : ''} found.`, + }; } - public async getUsersBasedOnQuery(query: QuerySchema) { - const users = await this.repo.query(query); + @Cacheable({ + cacheKey: (args) => qs.stringify(args[0]), + }) + public async getUsersByQuery(query: QuerySchema) { + const users = await this.repo.getAll(query); // run some formating and all need data manipulation - return { data: users, message: `${users.length} users found.` }; + return { + data: users.map(this.dto), + message: `${users.length} user${users.length > 1 ? 's' : ''} found.`, + }; } + @Cacheable({ cacheKey: ([id]) => id }) public async getUserById(id: string) { // run some formating and all need data manipulation const user = await this.repo.getById(id); - if (user) return user; + if (user) return this.dto(user); throw new AppError('No user found', NOT_FOUND); } - public async getOneUser( - query: { id: string; email: string } | { id?: string; email?: string }, - ) { - const users = await this.repo.getOne(query); - - // run some formating and all need data manipulation - return users; - } - - public async updateUser(id: string, payload: UpdateSchema) { - const [updatedRows] = await this.repo.update(id, payload); + @CacheUpdate({ + cacheKey: (args, ctx, result) => result.id, + cacheKeysToClear: () => ['users'], + }) + public async update(id: string, payload: UpdateSchema) { + const [updatedRows] = await this.repo.updateById(id, payload); if (updatedRows) { - return await this.getUserById(id); + const user = await this.repo.getById(id); + if (user) return this.dto(user); } throw new AppError('Unable to update, please try again.', BAD_REQUEST); } - public async softDeleteUserById(id: string) { + @CacheClear({ cacheKey: ([id]) => [id, 'users'] }) + public async softDeleteById(id: string) { // run some events, add to queue for possible full on deletions after 30days - return this.repo.delete(id); + return this.repo.deleteById(id); + } + + @CacheClear({ cacheKey: ([id]) => [id, 'users'] }) + public async forceDeleteById(id: string) { + return this.repo.deleteById(id, true); } } diff --git a/src/utils/appError.ts b/src/utils/appError.ts index 0847a41..d21d7cc 100644 --- a/src/utils/appError.ts +++ b/src/utils/appError.ts @@ -4,14 +4,17 @@ export class AppError extends Error { constructor( public message: string, public statusCode: number, - public errors?: string[], + public errors?: ( + | string + | Record + )[], public code?: number, ) { super(message); this.code = code; this.errors = errors; this.statusCode = statusCode; - this.status = ('' + statusCode).startsWith('4') ? 'fail' : 'error'; + this.status = ('' + statusCode).startsWith('4') ? 'failed' : 'error'; this.IsOperational = true; Error.captureStackTrace(this, this.constructor); diff --git a/src/utils/helper.ts b/src/utils/helper.ts index 0157cd2..40aab5d 100644 --- a/src/utils/helper.ts +++ b/src/utils/helper.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ export function isEmpty(value: unknown) { return ( value === undefined || @@ -6,3 +7,31 @@ export function isEmpty(value: unknown) { (typeof value === 'string' && value.trim().length === 0) ); } + +export function exclude>( + data: T, + keys: (keyof T)[], +) { + const _data = JSON.parse(JSON.stringify(data)) as T; + for (const key of keys) { + delete _data[key]; + } + + return _data; +} + +export function pick>(data: T, keys: (keyof T)[]) { + const _data = JSON.parse(JSON.stringify(data)) as T; + let value: Record | undefined; + for (const key of keys) { + if (!value) { + value = Object.create({ + [key]: _data[key], + }); + } else { + value[key] = _data[key]; + } + } + + return value; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index ab9c519..bb201dc 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,3 @@ +export * from './helper'; export { AppError } from './appError'; export { catchAsync } from './catchAsync'; diff --git a/src/validators/index.ts b/src/validators/index.ts index 24c3034..9141585 100644 --- a/src/validators/index.ts +++ b/src/validators/index.ts @@ -2,6 +2,14 @@ import z from 'zod'; export * from './user.zod.schema'; -export const miscSchema = (name: string) => { - return z.object({ [name]: z.string() }); -}; +export const ParamsWithId = z.object({ + id: z.string().uuid(), +}); + +export type ParamsWithId = z.infer; + +export const DeleteTypeSchema = z.object({ + type: z.enum(['soft', 'force']), +}); + +export type DeleteTypeSchema = z.infer; diff --git a/src/validators/user.zod.schema.ts b/src/validators/user.zod.schema.ts index 929e45c..ee60595 100644 --- a/src/validators/user.zod.schema.ts +++ b/src/validators/user.zod.schema.ts @@ -20,10 +20,7 @@ export const SignupSchema = z.object({ export type SignupSchema = z.infer; export const LoginSchema = z.object({ - lastName: z.string(), - firstName: z.string(), email: z.string().email(), - userType: z.enum(['0', '1', '2']), password: z .string() .min(6) @@ -69,12 +66,13 @@ export const UpdateSchema = z .object({ lastName: z.string().optional(), firstName: z.string().optional(), - userType: z.enum(['0', '1', '2']).optional(), + // userType: z.enum(['0', '1', '2']).optional(), dateOfBirth: z.date().optional(), }) .partial() - .refine((data) => isEmpty(data), { + .refine((data) => !isEmpty(data), { message: 'One of the fields must be defined', + path: ['firstName', 'lastName', 'dateOfBirth'], }); export type UpdateSchema = z.infer;