From 610f9240a41bb2c10e7ce719fb1849081f235da5 Mon Sep 17 00:00:00 2001 From: Abdou-Raouf ATARMLA Date: Sun, 23 Jun 2024 14:30:28 +0000 Subject: [PATCH 01/17] update: error codes extended --- src/app/utils/handlers/error/codes.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/app/utils/handlers/error/codes.ts b/src/app/utils/handlers/error/codes.ts index c05c867..19ffafe 100644 --- a/src/app/utils/handlers/error/codes.ts +++ b/src/app/utils/handlers/error/codes.ts @@ -44,6 +44,21 @@ const ErrorCodes: ErrorCodes = { message: 'The requested item was found.', statusCode: 302, }, + UNAUTHORIZED: { + code: 'UNAUTHORIZED', + message: 'Unauthorized', + statusCode: 401, + }, + FORBIDDEN: { + code: 'FORBIDDEN', + message: 'Forbidden', + statusCode: 403, + }, + INTERNAL_SERVER_ERROR: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Internal Server Error', + statusCode: 500, + }, }; export default ErrorCodes; From f0957f45ce3f3760f632c8aecc13f9fdf253a155 Mon Sep 17 00:00:00 2001 From: Abdou-Raouf ATARMLA Date: Sun, 23 Jun 2024 14:30:46 +0000 Subject: [PATCH 02/17] style: code reformatted --- commitlint.config.js | 98 ++++++++++++++++++++++---------------------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/commitlint.config.js b/commitlint.config.js index be0ff4a..ba0cd1b 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -15,57 +15,57 @@ // ----------------------------------------------------------------------------------------------------------------------------------------------------- module.exports = { - parserPreset: { - parserOpts: { - headerPattern: /^(\w+)(?:\((\w+)\))?:\s(.*)$/, - headerCorrespondence: ['type', 'scope', 'subject'], - }, + parserPreset: { + parserOpts: { + headerPattern: /^(\w+)(?:\((\w+)\))?:\s(.*)$/, + headerCorrespondence: ['type', 'scope', 'subject'], }, - plugins: [ - { - rules: { - 'header-match-team-pattern': (parsed) => { - const { type, subject } = parsed; - const allowedTypes = [ - 'build', - 'chore', - 'ci', - 'docs', - 'feat', - 'update', - 'fix', - 'perf', - 'refactor', - 'style', - 'test', - 'translation', - 'sec', + }, + plugins: [ + { + rules: { + 'header-match-team-pattern': (parsed) => { + const { type, subject } = parsed; + const allowedTypes = [ + 'build', + 'chore', + 'ci', + 'docs', + 'feat', + 'update', + 'fix', + 'perf', + 'refactor', + 'style', + 'test', + 'translation', + 'sec', + ]; + + if (!type || !subject) { + return [ + false, + "\x1b[31mERROR\x1b[0m: Please follow the format 'feat(auth): user login form' or 'fix: fixing data problems'", ]; - - if (!type || !subject) { - return [ - false, - "\x1b[31mERROR\x1b[0m: Please follow the format 'feat(auth): user login form' or 'fix: fixing data problems'", - ]; - } - - if (!allowedTypes.includes(type)) { - return [ - false, - `\x1b[31mERROR\x1b[0m: The commit type '${type}' is not allowed. Allowed types are: [${allowedTypes.join(', ')}]`, - ]; - } - - return [true, '']; - }, + } + + if (!allowedTypes.includes(type)) { + return [ + false, + `\x1b[31mERROR\x1b[0m: The commit type '${type}' is not allowed. Allowed types are: [${allowedTypes.join(', ')}]`, + ]; + } + + return [true, '']; }, }, - ], - rules: { - 'header-match-team-pattern': [2, 'always'], - 'subject-empty': [2, 'never'], - 'body-leading-blank': [2, 'always'], - 'footer-leading-blank': [2, 'always'], - 'footer-empty': [2, 'always'], }, - }; \ No newline at end of file + ], + rules: { + 'header-match-team-pattern': [2, 'always'], + 'subject-empty': [2, 'never'], + 'body-leading-blank': [2, 'always'], + 'footer-leading-blank': [2, 'always'], + 'footer-empty': [2, 'always'], + }, +}; From 1b012f5b990c90ef0c6c6b8891c63467206c57da Mon Sep 17 00:00:00 2001 From: Abdou-Raouf ATARMLA Date: Sun, 23 Jun 2024 14:31:12 +0000 Subject: [PATCH 03/17] feat: JWT service added --- .env.example | 9 + package.json | 2 + src/app/services/shared/jwt.service.ts | 234 ++++++++++++++++++ .../utils/middlewares/authenticate-request.ts | 12 + .../middlewares/client-authentication.ts | 4 +- src/config/index.ts | 24 ++ 6 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 src/app/services/shared/jwt.service.ts create mode 100644 src/app/utils/middlewares/authenticate-request.ts diff --git a/.env.example b/.env.example index d73c025..1da623a 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,13 @@ ENABLE_CLIENT_AUTH=true BASIC_AUTH_USER=admin BASIC_AUTH_PASS=secret +# JWT Tokens +ACCESS_TOKEN_SECRET=your-access-token-secret +ACCESS_TOKEN_EXPIRE_TIME=1h # Adjust as needed +REFRESH_TOKEN_SECRET=your-refresh-token-secret +REFRESH_TOKEN_EXPIRE_TIME=7d # Adjust as needed +TOKEN_ISSUER=your-issuer + # Database DB_URI=mongodb://mongo:27017 DB_NAME=mydatabase @@ -15,6 +22,8 @@ MONGO_CLIENT_PORT=9005 # Cache REDIS_HOST=redis REDIS_SERVER_PORT=9079 +REDIS_TOKEN_EXPIRE_TIME=31536000 # 1 year in seconds (validity for refresh token) +REDIS_BLACKLIST_EXPIRE_TIME=2592000 # 1 month in seconds # MinIO MINIO_ENDPOINT=minio diff --git a/package.json b/package.json index db9606d..2079804 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "http-errors": "^2.0.0", "ioredis": "^5.4.1", "joi": "^17.13.3", + "jsonwebtoken": "^9.0.2", "minio": "^8.0.0", "mongoose": "^8.4.3", "morgan": "^1.10.0" @@ -54,6 +55,7 @@ "@types/express": "^4.17.21", "@types/express-brute": "^1.0.5", "@types/express-brute-mongo": "^0.0.39", + "@types/jsonwebtoken": "^9.0.6", "@types/morgan": "^1.9.9", "@typescript-eslint/eslint-plugin": "^5.57.1", "@typescript-eslint/parser": "^5.57.1", diff --git a/src/app/services/shared/jwt.service.ts b/src/app/services/shared/jwt.service.ts new file mode 100644 index 0000000..18f0b98 --- /dev/null +++ b/src/app/services/shared/jwt.service.ts @@ -0,0 +1,234 @@ +import JWT, { SignOptions } from 'jsonwebtoken'; +import { getClient } from '../../../framework/database/redis/redis'; +import ErrorResponse from '../../utils/handlers/error/response'; +import config from '../../../config'; +import ApiResponse from '../../utils/handlers/api-reponse'; + +const client = getClient(); + +class JwtService { + private accessTokenSecret: string; + private refreshTokenSecret: string; + private accessTokenExpireTime: string; + private refreshTokenExpireTime: string; + private tokenIssuer: string; + private redisTokenExpireTime: number; + private redisBlacklistExpireTime: number; + + constructor() { + this.accessTokenSecret = config.jwt.accessTokenSecret; + this.refreshTokenSecret = config.jwt.refreshTokenSecret; + this.accessTokenExpireTime = config.jwt.accessTokenExpireTime; + this.refreshTokenExpireTime = config.jwt.refreshTokenExpireTime; + this.tokenIssuer = config.jwt.tokenIssuer; + this.redisTokenExpireTime = config.redis.tokenExpireTime; + this.redisBlacklistExpireTime = config.redis.blacklistExpireTime; + } + + signAccessToken(userId: string): Promise { + return new Promise((resolve, reject) => { + const payload = {}; + const options: SignOptions = { + expiresIn: this.accessTokenExpireTime, + issuer: this.tokenIssuer, + audience: userId, + }; + + JWT.sign( + payload, + this.accessTokenSecret, + options, + (err: any, token?: string) => { + if (err || !token) { + console.error(err?.message); + const errorResponse = new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + 'Internal Server Error', + ); + return reject(errorResponse); + } + resolve(token); + }, + ); + }); + } + + async isTokenBlacklisted(token: string): Promise { + return new Promise((resolve, reject) => { + client.get(`bl_${token}`, (err: any, result: any) => { + if (err) { + console.error(err.message); + const errorResponse = new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + 'Internal Server Error', + ); + return reject(errorResponse); + } + resolve(result === 'blacklisted'); + }); + }); + } + + verifyAccessToken(req: any, res: any, next: any): void { + if (!req.headers['authorization']) { + const errorResponse = new ErrorResponse('UNAUTHORIZED', 'Unauthorized', [ + 'No authorization header', + ]); + return ApiResponse.error(res, { + success: false, + error: errorResponse, + }) as any; + } + + const authHeader = req.headers['authorization']; + const bearerToken = authHeader.split(' '); + const token = bearerToken[1]; + + JWT.verify( + token, + this.accessTokenSecret, + async (err: any, payload: any) => { + if (err) { + const message = + err.name === 'JsonWebTokenError' ? 'Unauthorized' : err.message; + const errorResponse = new ErrorResponse('UNAUTHORIZED', message); + return ApiResponse.error(res, { + success: false, + error: errorResponse, + }); + } + + try { + const blacklisted = await this.isTokenBlacklisted(token); + if (blacklisted) { + const errorResponse = new ErrorResponse('FORBIDDEN', 'Forbidden', [ + 'Token is blacklisted', + ]); + return ApiResponse.error(res, { + success: false, + error: errorResponse, + }); + } + } catch (error) { + return ApiResponse.error(res, { + success: false, + error: error as ErrorResponse, + }); + } + + req.payload = payload; + next(); + }, + ); + } + + signRefreshToken(userId: string): Promise { + return new Promise((resolve, reject) => { + const payload = {}; + const options: SignOptions = { + expiresIn: this.refreshTokenExpireTime, + issuer: this.tokenIssuer, + audience: userId, + }; + + JWT.sign( + payload, + this.refreshTokenSecret, + options, + (err: any, token?: string) => { + if (err || !token) { + console.error(err?.message); + const errorResponse = new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + 'Internal Server Error', + ); + return reject(errorResponse); + } + + client.set( + userId, + token, + 'EX', + this.redisTokenExpireTime, + (redisErr: any) => { + if (redisErr) { + console.error(redisErr.message); + const errorResponse = new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + 'Internal Server Error', + ); + return reject(errorResponse); + } + resolve(token); + }, + ); + }, + ); + }); + } + + verifyRefreshToken(refreshToken: string): Promise { + return new Promise((resolve, reject) => { + JWT.verify( + refreshToken, + this.refreshTokenSecret, + (err: any, payload: any) => { + if (err) { + const errorResponse = new ErrorResponse( + 'UNAUTHORIZED', + 'Unauthorized', + ); + return reject(errorResponse); + } + + const userId = payload?.aud as string; + + client.get(userId, (redisErr: any, result: any) => { + if (redisErr) { + console.error(redisErr.message); + const errorResponse = new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + 'Internal Server Error', + ); + return reject(errorResponse); + } + + if (refreshToken === result) { + return resolve(userId); + } + + const errorResponse = new ErrorResponse( + 'UNAUTHORIZED', + 'Unauthorized', + ); + return reject(errorResponse); + }); + }, + ); + }); + } + + blacklistToken(token: string): Promise { + return new Promise((resolve, reject) => { + client.set( + `bl_${token}`, + 'blacklisted', + 'EX', + this.redisBlacklistExpireTime, + (redisErr: any) => { + if (redisErr) { + console.error(redisErr.message); + const errorResponse = new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + 'Internal Server Error', + ); + return reject(errorResponse); + } + resolve(); + }, + ); + }); + } +} + +export default new JwtService(); diff --git a/src/app/utils/middlewares/authenticate-request.ts b/src/app/utils/middlewares/authenticate-request.ts new file mode 100644 index 0000000..48eb82b --- /dev/null +++ b/src/app/utils/middlewares/authenticate-request.ts @@ -0,0 +1,12 @@ +import { Request, Response, NextFunction } from 'express'; +import jwtService from '../../services/shared/jwt.service'; + +const authenticateRequest = ( + req: Request, + res: Response, + next: NextFunction, +) => { + jwtService.verifyAccessToken(req, res, next); +}; + +export default authenticateRequest; diff --git a/src/app/utils/middlewares/client-authentication.ts b/src/app/utils/middlewares/client-authentication.ts index aabfac5..cb9a2b6 100644 --- a/src/app/utils/middlewares/client-authentication.ts +++ b/src/app/utils/middlewares/client-authentication.ts @@ -9,7 +9,7 @@ export const clientAuthentication = ( if (!auth) { res.setHeader('WWW-Authenticate', 'Basic'); - return res.status(401).send('Authentication required.'); + return res.status(401).send('Unauthorized'); } const [username, password] = Buffer.from(auth.split(' ')[1], 'base64') @@ -22,6 +22,6 @@ export const clientAuthentication = ( if (username === validUser && password === validPass) { return next(); } else { - return res.status(403).send('Unauthorized'); + return res.status(403).send('Forbidden'); } }; diff --git a/src/config/index.ts b/src/config/index.ts index c5cbad0..12b3e15 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -9,6 +9,13 @@ interface Config { enableClientAuth: boolean; basicAuthUser: string; basicAuthPass: string; + jwt: { + accessTokenSecret: string; + refreshTokenSecret: string; + accessTokenExpireTime: string; + refreshTokenExpireTime: string; + tokenIssuer: string; + }; rate: { limit: number; max: number; @@ -28,6 +35,8 @@ interface Config { host: string; port: number; serverPort: number; + tokenExpireTime: number; + blacklistExpireTime: number; }; minio: { endpoint: string; @@ -51,6 +60,13 @@ const config: Config = { enableClientAuth: process.env.ENABLE_CLIENT_AUTH === 'true', basicAuthUser: process.env.BASIC_AUTH_USER || 'admin', basicAuthPass: process.env.BASIC_AUTH_PASS || 'secret', + jwt: { + accessTokenSecret: process.env.ACCESS_TOKEN_SECRET || '', + refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET || '', + accessTokenExpireTime: process.env.ACCESS_TOKEN_EXPIRE_TIME || '1h', + refreshTokenExpireTime: process.env.REFRESH_TOKEN_EXPIRE_TIME || '7d', + tokenIssuer: process.env.TOKEN_ISSUER || 'your-issuer', + }, rate: { limit: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes in milliseconds max: parseInt(process.env.RATE_LIMIT_MAX || '100', 10), @@ -70,6 +86,14 @@ const config: Config = { host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379', 10), serverPort: parseInt(process.env.REDIS_SERVER_PORT || '9079', 10), + tokenExpireTime: parseInt( + process.env.REDIS_TOKEN_EXPIRE_TIME || '31536000', + 10, + ), // 1 year in seconds + blacklistExpireTime: parseInt( + process.env.REDIS_BLACKLIST_EXPIRE_TIME || '2592000', + 10, + ), // 1 month in seconds }, minio: { endpoint: process.env.MINIO_ENDPOINT || 'localhost', From f1e325edd1d7ae9aee9eb5983bf030410b673f08 Mon Sep 17 00:00:00 2001 From: Abdou-Raouf ATARMLA Date: Sun, 23 Jun 2024 16:03:32 +0000 Subject: [PATCH 04/17] update: jwt service enhanced --- src/app/services/shared/jwt.service.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/app/services/shared/jwt.service.ts b/src/app/services/shared/jwt.service.ts index 18f0b98..06adace 100644 --- a/src/app/services/shared/jwt.service.ts +++ b/src/app/services/shared/jwt.service.ts @@ -229,6 +229,22 @@ class JwtService { ); }); } + + removeFromRedis(key: string): Promise { + return new Promise((resolve, reject) => { + client.del(key, (redisErr: any) => { + if (redisErr) { + console.error(redisErr.message); + const errorResponse = new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + 'Internal Server Error', + ); + return reject(errorResponse); + } + resolve(); + }); + }); + } } export default new JwtService(); From b6eb15b4e4d7a23c3a747fb635891c27f53214b6 Mon Sep 17 00:00:00 2001 From: Abdou-Raouf ATARMLA Date: Sun, 23 Jun 2024 16:04:25 +0000 Subject: [PATCH 05/17] update: user manipulation module in progress --- .env.example | 3 ++ package.json | 2 + src/app/models/user.model.ts | 20 +++++++- src/app/services/user.service.ts | 86 +++++++++++++++++++++++++++++++- src/config/index.ts | 6 +++ 5 files changed, 115 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 1da623a..f07c4b5 100644 --- a/.env.example +++ b/.env.example @@ -47,3 +47,6 @@ BRUTE_FORCE_FREE_RETRIES=5 BRUTE_FORCE_MIN_WAIT=300000 # 5 minutes in milliseconds BRUTE_FORCE_MAX_WAIT=3600000 # 1 hour in milliseconds BRUTE_FORCE_LIFETIME=86400 # 1 day in seconds + +# Bcrypt +BCRYPT_SALT_ROUNDS=10 diff --git a/package.json b/package.json index 2079804..882e98a 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "author": "Abdou-Raouf ATARMLA", "license": "ISC", "dependencies": { + "@types/bcrypt": "^5.0.2", + "bcrypt": "^5.1.1", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", diff --git a/src/app/models/user.model.ts b/src/app/models/user.model.ts index cb901df..695d8da 100644 --- a/src/app/models/user.model.ts +++ b/src/app/models/user.model.ts @@ -1,5 +1,7 @@ -import { Schema, model } from 'mongoose'; +import { Schema, model, CallbackError } from 'mongoose'; +import bcrypt from 'bcrypt'; import { IUserModel } from '../utils/types'; +import config from '../../config'; const UserSchema: Schema = new Schema( { @@ -9,10 +11,26 @@ const UserSchema: Schema = new Schema( password: { type: String, required: true }, role: { type: String, enum: ['admin', 'user', 'guest'], default: 'user' }, profilePhoto: { type: String }, + active: { type: Boolean, default: true }, + verified: { type: Boolean, default: false }, }, { timestamps: true }, ); +// Pre-hook for hashing the password before saving +UserSchema.pre('save', async function (next) { + try { + if (this.isNew || this.isModified('password')) { + const salt = await bcrypt.genSalt(config.bcrypt.saltRounds); + const hashedPassword = await bcrypt.hash(this.password as string, salt); + this.password = hashedPassword; + } + next(); + } catch (error) { + next(error as CallbackError); + } +}); + const UserModel = model('User', UserSchema); export default UserModel; diff --git a/src/app/services/user.service.ts b/src/app/services/user.service.ts index 8bcd72c..c1f46aa 100644 --- a/src/app/services/user.service.ts +++ b/src/app/services/user.service.ts @@ -1,7 +1,14 @@ +import config from '../../config'; import UserModel from '../models/user.model'; import UserRepository from '../repositories/user.repo'; -import { IUserModel } from '../utils/types'; +import ErrorResponse from '../utils/handlers/error/response'; +import { + ErrorResponseType, + IUserModel, + SuccessResponseType, +} from '../utils/types'; import { BaseService } from './base.service'; +import bcrypt from 'bcrypt'; class UserService extends BaseService { constructor() { @@ -9,6 +16,83 @@ class UserService extends BaseService { super(userRepo, true /*, ['profilePicture']*/); this.searchFields = ['firstName', 'lastName', 'email']; } + + async isValidPassword( + userId: string, + password: string, + ): Promise | ErrorResponseType> { + try { + const response = (await this.findOne({ + _id: userId, + })) as SuccessResponseType; + if (!response.success || !response.document) { + return { + success: false, + error: response.error, + }; + } + + const isValid = await bcrypt.compare( + password, + response.document.password, + ); + return { success: true, document: { isValid } }; + } catch (error) { + return { + success: false, + error: + error instanceof ErrorResponse + ? error + : new ErrorResponse('UNKNOWN_ERROR', (error as Error).message), + }; + } + } + + async updatePassword( + userId: string, + newPassword: string, + ): Promise | ErrorResponseType> { + try { + const response = (await this.findOne({ + _id: userId, + })) as SuccessResponseType; + if (!response.success || !response.document) { + return { + success: false, + error: response.error, + }; + } + + const hashedPassword = await bcrypt.hash( + newPassword, + config.bcrypt.saltRounds, + ); + const updateResponse = (await this.update( + { _id: userId }, + { password: hashedPassword }, + )) as SuccessResponseType; + + if (!updateResponse.success) { + return { + success: false, + error: updateResponse.error!, + }; + } + + return { + success: true, + document: updateResponse.document, + }; + } catch (error) { + return { + success: false, + error: + error instanceof ErrorResponse + ? error + : new ErrorResponse('UNKNOWN_ERROR', (error as Error).message), + }; + } + } } export default new UserService(); diff --git a/src/config/index.ts b/src/config/index.ts index 12b3e15..0702010 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -9,6 +9,9 @@ interface Config { enableClientAuth: boolean; basicAuthUser: string; basicAuthPass: string; + bcrypt: { + saltRounds: number; + }; jwt: { accessTokenSecret: string; refreshTokenSecret: string; @@ -60,6 +63,9 @@ const config: Config = { enableClientAuth: process.env.ENABLE_CLIENT_AUTH === 'true', basicAuthUser: process.env.BASIC_AUTH_USER || 'admin', basicAuthPass: process.env.BASIC_AUTH_PASS || 'secret', + bcrypt: { + saltRounds: parseInt(process.env.BCRYPT_SALT_ROUNDS || '10', 10), + }, jwt: { accessTokenSecret: process.env.ACCESS_TOKEN_SECRET || '', refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET || '', From f9ffc9de28dc865091f9d0c4673a6f8a21f8ca74 Mon Sep 17 00:00:00 2001 From: Abdou-Raouf ATARMLA Date: Sun, 23 Jun 2024 16:59:07 +0000 Subject: [PATCH 06/17] feat: auth service added - user service updated --- src/app/services/auth.service.ts | 348 ++++++++++++++++++++++++++ src/app/services/user.service.ts | 70 ++++++ src/app/utils/handlers/error/codes.ts | 5 + src/app/utils/types/user.ts | 2 + 4 files changed, 425 insertions(+) create mode 100644 src/app/services/auth.service.ts diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts new file mode 100644 index 0000000..167af66 --- /dev/null +++ b/src/app/services/auth.service.ts @@ -0,0 +1,348 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import UserService from '../services/user.service'; +import { + SuccessResponseType, + ErrorResponseType, + IUserModel, +} from '../utils/types'; +import ErrorResponse from '../utils/handlers/error/response'; +import JwtService from './shared/jwt.service'; + +class AuthService { + static async register( + payload: any, + ): Promise | ErrorResponseType> { + try { + const { email } = payload; + const existingUserResponse = (await UserService.findOne({ + email, + })) as SuccessResponseType; + if (existingUserResponse.success && existingUserResponse.document) { + return { + success: false, + error: new ErrorResponse( + 'VALIDATION_ERROR', + 'The entered email is already registered.', + ), + }; + } + + const createUserResponse = (await UserService.create( + payload, + )) as SuccessResponseType; + if (!createUserResponse.success) { + return createUserResponse; + } + + // TODO: Generate and send OTP for account verification + + return { success: true, document: createUserResponse.document }; + } catch (error) { + return { + success: false, + error: + error instanceof ErrorResponse + ? error + : new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + (error as Error).message, + ), + }; + } + } + + static async verifyAccount( + payload: any, + ): Promise | ErrorResponseType> { + try { + const { email, code } = payload; + + const isVerifiedResponse = (await UserService.isVerified( + email, + )) as SuccessResponseType; + if (isVerifiedResponse.success && isVerifiedResponse.document?.verified) { + return { + success: false, + error: new ErrorResponse( + 'VALIDATION_ERROR', + 'Account is already verified.', + ), + }; + } + + // TODO: Validate OTP for account verification + + const verifyUserResponse = (await UserService.markAsVerified( + email, + )) as SuccessResponseType; + if (!verifyUserResponse.success) { + return verifyUserResponse; + } + + // TODO: Send verification success email + + return { success: true, document: verifyUserResponse.document }; + } catch (error) { + return { + success: false, + error: + error instanceof ErrorResponse + ? error + : new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + (error as Error).message, + ), + }; + } + } + + static async login( + payload: any, + ): Promise | ErrorResponseType> { + try { + const { email, password } = payload; + const existingUserResponse = (await UserService.findOne({ + email, + })) as SuccessResponseType; + if (!existingUserResponse.success || !existingUserResponse.document) { + return { + success: false, + error: new ErrorResponse('UNAUTHORIZED', 'Invalid credentials.'), + }; + } + + const user = existingUserResponse.document; + + const isValidPasswordResponse = (await UserService.isValidPassword( + user.id, + password, + )) as SuccessResponseType<{ isValid: boolean }>; + if ( + !isValidPasswordResponse.success || + !isValidPasswordResponse.document?.isValid + ) { + return { + success: false, + error: new ErrorResponse('UNAUTHORIZED', 'Invalid credentials.'), + }; + } + + if (!user.verified) { + return { + success: false, + error: new ErrorResponse('UNAUTHORIZED', 'Unverified account.'), + }; + } + + if (!user.active) { + return { + success: false, + error: new ErrorResponse( + 'FORBIDDEN', + 'Inactive account, please contact admins.', + ), + }; + } + + const accessToken = await JwtService.signAccessToken(user.id); + const refreshToken = await JwtService.signRefreshToken(user.id); + + return { + success: true, + document: { token: { accessToken, refreshToken }, user }, + }; + } catch (error) { + return { + success: false, + error: + error instanceof ErrorResponse + ? error + : new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + (error as Error).message, + ), + }; + } + } + + static async refresh( + refreshToken: string, + ): Promise | ErrorResponseType> { + try { + if (!refreshToken) { + return { + success: false, + error: new ErrorResponse('BAD_REQUEST', 'Refresh token is required.'), + }; + } + + const userId = await JwtService.verifyRefreshToken(refreshToken); + const accessToken = await JwtService.signAccessToken(userId); + const newRefreshToken = await JwtService.signRefreshToken(userId); + + return { + success: true, + document: { token: { accessToken, refreshToken: newRefreshToken } }, + }; + } catch (error) { + return { + success: false, + error: + error instanceof ErrorResponse + ? error + : new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + (error as Error).message, + ), + }; + } + } + + static async logout( + refreshToken: string, + ): Promise | ErrorResponseType> { + try { + if (!refreshToken) { + return { + success: false, + error: new ErrorResponse('BAD_REQUEST', 'Refresh token is required.'), + }; + } + + const userId = await JwtService.verifyRefreshToken(refreshToken); + await JwtService.removeFromRedis(userId); + + return { success: true, document: null }; + } catch (error) { + return { + success: false, + error: + error instanceof ErrorResponse + ? error + : new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + (error as Error).message, + ), + }; + } + } + + static async forgotPassword( + email: string, + ): Promise | ErrorResponseType> { + try { + if (!email) { + return { + success: false, + error: new ErrorResponse('BAD_REQUEST', 'Email should be provided.'), + }; + } + + const existingUserResponse = (await UserService.findOne({ + email, + })) as SuccessResponseType; + if (!existingUserResponse.success || !existingUserResponse.document) { + return { + success: false, + error: new ErrorResponse('NOT_FOUND_ERROR', 'User not found.'), + }; + } + + const user = existingUserResponse.document; + + if (!user.verified) { + return { + success: false, + error: new ErrorResponse('UNAUTHORIZED', 'Unverified account.'), + }; + } + + if (!user.active) { + return { + success: false, + error: new ErrorResponse( + 'FORBIDDEN', + 'Inactive account, please contact admins.', + ), + }; + } + + // TODO: Generate and send OTP for password reset + + return { success: true, document: null }; + } catch (error) { + return { + success: false, + error: + error instanceof ErrorResponse + ? error + : new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + (error as Error).message, + ), + }; + } + } + + static async resetPassword( + payload: any, + ): Promise | ErrorResponseType> { + try { + const { email, code, newPassword } = payload; + + const existingUserResponse = (await UserService.findOne({ + email, + })) as SuccessResponseType; + if (!existingUserResponse.success || !existingUserResponse.document) { + return { + success: false, + error: new ErrorResponse('NOT_FOUND_ERROR', 'User not found.'), + }; + } + + const user = existingUserResponse.document; + + if (!user.verified) { + return { + success: false, + error: new ErrorResponse('UNAUTHORIZED', 'Unverified account.'), + }; + } + + if (!user.active) { + return { + success: false, + error: new ErrorResponse( + 'FORBIDDEN', + 'Inactive account, please contact admins.', + ), + }; + } + + // TODO: Validate OTP for password reset + + const updatePasswordResponse = (await UserService.updatePassword( + user.id, + newPassword, + )) as SuccessResponseType; + if (!updatePasswordResponse.success) { + return updatePasswordResponse; + } + + return { success: true, document: null }; + } catch (error) { + return { + success: false, + error: + error instanceof ErrorResponse + ? error + : new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + (error as Error).message, + ), + }; + } + } +} + +export default AuthService; diff --git a/src/app/services/user.service.ts b/src/app/services/user.service.ts index c1f46aa..a5d7c3a 100644 --- a/src/app/services/user.service.ts +++ b/src/app/services/user.service.ts @@ -93,6 +93,76 @@ class UserService extends BaseService { }; } } + + async isVerified( + email: string, + ): Promise | ErrorResponseType> { + try { + const response = (await this.findOne({ + email, + })) as SuccessResponseType; + if (!response.success || !response.document) { + return { + success: false, + error: response.error, + }; + } + + return { + success: true, + document: { verified: response.document.verified }, + }; + } catch (error) { + return { + success: false, + error: + error instanceof ErrorResponse + ? error + : new ErrorResponse('UNKNOWN_ERROR', (error as Error).message), + }; + } + } + + async markAsVerified( + email: string, + ): Promise | ErrorResponseType> { + try { + const response = (await this.findOne({ + email, + })) as SuccessResponseType; + if (!response.success || !response.document) { + return { + success: false, + error: response.error, + }; + } + + const updateResponse = (await this.update( + { _id: response.document._id }, + { verified: true }, + )) as SuccessResponseType; + + if (!updateResponse.success) { + return { + success: false, + error: updateResponse.error, + }; + } + + return { + success: true, + document: updateResponse.document, + }; + } catch (error) { + return { + success: false, + error: + error instanceof ErrorResponse + ? error + : new ErrorResponse('UNKNOWN_ERROR', (error as Error).message), + }; + } + } } export default new UserService(); diff --git a/src/app/utils/handlers/error/codes.ts b/src/app/utils/handlers/error/codes.ts index 19ffafe..a7417fa 100644 --- a/src/app/utils/handlers/error/codes.ts +++ b/src/app/utils/handlers/error/codes.ts @@ -39,6 +39,11 @@ const ErrorCodes: ErrorCodes = { message: 'Required field(s) missing.', statusCode: 400, }, + BAD_REQUEST: { + code: 'BAD_REQUEST', + message: 'Required field(s) missing.', + statusCode: 400, + }, FOUND: { code: 'FOUND', message: 'The requested item was found.', diff --git a/src/app/utils/types/user.ts b/src/app/utils/types/user.ts index f734e37..3da3d69 100644 --- a/src/app/utils/types/user.ts +++ b/src/app/utils/types/user.ts @@ -9,6 +9,8 @@ export interface IUser { password: string; role: TUserRole; profilePhoto?: string; + verified: boolean; + active: boolean; createdAt?: Date; updatedAt?: Date; } From 1d7f8c96420a08a96b8b451ff0b5e55c38004b87 Mon Sep 17 00:00:00 2001 From: Abdou-Raouf ATARMLA Date: Thu, 27 Jun 2024 09:44:51 +0000 Subject: [PATCH 07/17] feat: mail service and options added --- .env.example | 10 ++++ package.json | 8 +-- src/app/services/shared/mail.service.ts | 70 +++++++++++++++++++++++++ src/config/index.ts | 46 ++++++++++------ src/templates/mail/welcome.html | 10 ++++ 5 files changed, 125 insertions(+), 19 deletions(-) create mode 100644 src/app/services/shared/mail.service.ts diff --git a/.env.example b/.env.example index f07c4b5..2210ae2 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,16 @@ MAILDEV_PORT=1025 MAILDEV_SMTP=9025 MAILDEV_WEBAPP_PORT=9080 +# SMTP (for production) +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=your-smtp-username +SMTP_PASS=your-smtp-password + +# Mail Senders +FROM_EMAIL=no-reply@myapp.com +FROM_NAME="Your Service Name" + # Rate Limiting RATE_LIMIT_WINDOW_MS=900000 # 15 minutes in milliseconds RATE_LIMIT_MAX=100 # 100 requests per windowMs diff --git a/package.json b/package.json index 882e98a..5e95a32 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "author": "Abdou-Raouf ATARMLA", "license": "ISC", "dependencies": { - "@types/bcrypt": "^5.0.2", "bcrypt": "^5.1.1", "cors": "^2.8.5", "dotenv": "^16.4.5", @@ -41,14 +40,15 @@ "express-brute-mongo": "^1.0.0", "express-brute-redis": "^0.0.1", "express-rate-limit": "^7.3.1", + "handlebars": "^4.7.8", "helmet": "^7.1.0", - "http-errors": "^2.0.0", "ioredis": "^5.4.1", "joi": "^17.13.3", "jsonwebtoken": "^9.0.2", "minio": "^8.0.0", "mongoose": "^8.4.3", - "morgan": "^1.10.0" + "morgan": "^1.10.0", + "nodemailer": "^6.9.14" }, "devDependencies": { "@commitlint/cli": "^19.3.0", @@ -61,6 +61,8 @@ "@types/morgan": "^1.9.9", "@typescript-eslint/eslint-plugin": "^5.57.1", "@typescript-eslint/parser": "^5.57.1", + "@types/bcrypt": "^5.0.2", + "@types/nodemailer": "^6.4.15", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.29.1", diff --git a/src/app/services/shared/mail.service.ts b/src/app/services/shared/mail.service.ts new file mode 100644 index 0000000..5e3711e --- /dev/null +++ b/src/app/services/shared/mail.service.ts @@ -0,0 +1,70 @@ +import nodemailer, { Transporter } from 'nodemailer'; +import handlebars from 'handlebars'; +import fs from 'fs'; +import path from 'path'; +import config from '../../../config'; + +class MailService { + private transporter: Transporter; + + constructor() { + this.transporter = nodemailer.createTransport({ + host: config.mail.host, + port: config.mail.port, + secure: config.runningProd && config.mail.port === 465, // true for 465, false for other ports + auth: config.runningProd + ? { + user: config.mail.user, + pass: config.mail.pass, + } + : undefined, + }); + } + + async sendMail({ + to, + subject, + text, + htmlTemplate, + templateData, + fromName, + fromEmail, + }: { + to: string; + subject: string; + text?: string; + htmlTemplate?: string; + templateData?: Record; + fromName?: string; + fromEmail?: string; + }): Promise { + try { + let htmlContent; + if (htmlTemplate) { + const templatePath = path.join( + __dirname, + '../../templates/mail/', + `${htmlTemplate}.html`, + ); + const templateSource = fs.readFileSync(templatePath, 'utf-8'); + const template = handlebars.compile(templateSource); + htmlContent = template(templateData); + } + + const mailOptions = { + from: `"${fromName || config.mail.fromName}" <${fromEmail || config.mail.from}>`, + to, + subject, + text, + html: htmlContent, + }; + + await this.transporter.sendMail(mailOptions); + } catch (error) { + console.error('Error sending email:', error); + throw new Error('Failed to send email'); + } + } +} + +export default new MailService(); diff --git a/src/config/index.ts b/src/config/index.ts index 0702010..3ae5c6c 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -9,9 +9,6 @@ interface Config { enableClientAuth: boolean; basicAuthUser: string; basicAuthPass: string; - bcrypt: { - saltRounds: number; - }; jwt: { accessTokenSecret: string; refreshTokenSecret: string; @@ -48,11 +45,16 @@ interface Config { apiPort: number; consolePort: number; }; - maildev: { + mail: { host: string; port: number; - smtpPort: number; - webappPort: number; + user: string; + pass: string; + from: string; + fromName: string; + }; + bcrypt: { + saltRounds: number; }; } @@ -63,9 +65,6 @@ const config: Config = { enableClientAuth: process.env.ENABLE_CLIENT_AUTH === 'true', basicAuthUser: process.env.BASIC_AUTH_USER || 'admin', basicAuthPass: process.env.BASIC_AUTH_PASS || 'secret', - bcrypt: { - saltRounds: parseInt(process.env.BCRYPT_SALT_ROUNDS || '10', 10), - }, jwt: { accessTokenSecret: process.env.ACCESS_TOKEN_SECRET || '', refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET || '', @@ -95,11 +94,11 @@ const config: Config = { tokenExpireTime: parseInt( process.env.REDIS_TOKEN_EXPIRE_TIME || '31536000', 10, - ), // 1 year in seconds + ), blacklistExpireTime: parseInt( process.env.REDIS_BLACKLIST_EXPIRE_TIME || '2592000', 10, - ), // 1 month in seconds + ), }, minio: { endpoint: process.env.MINIO_ENDPOINT || 'localhost', @@ -108,11 +107,26 @@ const config: Config = { apiPort: parseInt(process.env.MINIO_API_PORT || '9500', 10), consolePort: parseInt(process.env.MINIO_CONSOLE_PORT || '9050', 10), }, - maildev: { - host: process.env.MAILDEV_HOST || 'localhost', - port: parseInt(process.env.MAILDEV_PORT || '1025', 10), - smtpPort: parseInt(process.env.MAILDEV_SMTP || '9025', 10), - webappPort: parseInt(process.env.MAILDEV_WEBAPP_PORT || '9080', 10), + mail: { + host: + process.env.NODE_ENV === 'production' + ? process.env.SMTP_HOST || '' + : process.env.MAILDEV_HOST || 'localhost', + port: parseInt( + process.env.NODE_ENV === 'production' + ? process.env.SMTP_PORT || '587' + : process.env.MAILDEV_PORT || '1025', + 10, + ), + user: + process.env.NODE_ENV === 'production' ? process.env.SMTP_USER || '' : '', + pass: + process.env.NODE_ENV === 'production' ? process.env.SMTP_PASS || '' : '', + from: process.env.FROM_EMAIL || 'no-reply@myapp.com', + fromName: process.env.FROM_NAME || 'Your Service Name', + }, + bcrypt: { + saltRounds: parseInt(process.env.BCRYPT_SALT_ROUNDS || '10', 10), }, }; diff --git a/src/templates/mail/welcome.html b/src/templates/mail/welcome.html index e69de29..dc63d0d 100644 --- a/src/templates/mail/welcome.html +++ b/src/templates/mail/welcome.html @@ -0,0 +1,10 @@ + + + + Welcome + + +

Hello, {{name}}!

+

Welcome to our service. We are glad to have you with us.

+ + From f36fd0abe3a35677f6d6ff8598a219a4186c2331 Mon Sep 17 00:00:00 2001 From: Abdou-Raouf ATARMLA Date: Thu, 27 Jun 2024 13:55:18 +0000 Subject: [PATCH 08/17] feat: view engine added --- .env.example | 6 ++++ package.json | 8 +++++ src/app/controllers/app.controller.ts | 23 ++++++++++++ src/app/routes/app.routes.ts | 8 +++++ src/app/routes/routes.ts | 2 ++ src/app/services/shared/view.service.ts | 47 +++++++++++++++++++++++++ src/config/index.ts | 10 ++++++ src/framework/session-flash/index.ts | 16 +++++++++ src/framework/view-engine/ejs.ts | 8 +++++ src/framework/view-engine/handlebars.ts | 1 + src/framework/view-engine/index.ts | 23 ++++++++++++ src/framework/view-engine/nunjucks.ts | 1 + src/framework/view-engine/pug.ts | 8 +++++ src/framework/webserver/express.ts | 19 ++++++---- tailwind.config.js | 9 +++++ views/index.ejs | 26 ++++++++++++++ 16 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 src/app/controllers/app.controller.ts create mode 100644 src/app/routes/app.routes.ts create mode 100644 src/app/services/shared/view.service.ts create mode 100644 src/framework/session-flash/index.ts create mode 100644 src/framework/view-engine/ejs.ts create mode 100644 src/framework/view-engine/handlebars.ts create mode 100644 src/framework/view-engine/index.ts create mode 100644 src/framework/view-engine/nunjucks.ts create mode 100644 src/framework/view-engine/pug.ts create mode 100644 tailwind.config.js create mode 100644 views/index.ejs diff --git a/.env.example b/.env.example index d73c025..3972029 100644 --- a/.env.example +++ b/.env.example @@ -38,3 +38,9 @@ BRUTE_FORCE_FREE_RETRIES=5 BRUTE_FORCE_MIN_WAIT=300000 # 5 minutes in milliseconds BRUTE_FORCE_MAX_WAIT=3600000 # 1 hour in milliseconds BRUTE_FORCE_LIFETIME=86400 # 1 day in seconds + +# Session +SESSION_SESSION_SECRET="mysessionsecret" + +#View engine +VIEW_ENGINE=ejs \ No newline at end of file diff --git a/package.json b/package.json index db9606d..5d086e0 100644 --- a/package.json +++ b/package.json @@ -32,13 +32,18 @@ "author": "Abdou-Raouf ATARMLA", "license": "ISC", "dependencies": { + "@types/connect-flash": "^0.0.40", + "@types/express-session": "^1.18.0", + "connect-flash": "^0.1.1", "cors": "^2.8.5", "dotenv": "^16.4.5", + "ejs": "^3.1.10", "express": "^4.19.2", "express-brute": "^1.0.1", "express-brute-mongo": "^1.0.0", "express-brute-redis": "^0.0.1", "express-rate-limit": "^7.3.1", + "express-session": "^1.18.0", "helmet": "^7.1.0", "http-errors": "^2.0.0", "ioredis": "^5.4.1", @@ -57,6 +62,7 @@ "@types/morgan": "^1.9.9", "@typescript-eslint/eslint-plugin": "^5.57.1", "@typescript-eslint/parser": "^5.57.1", + "autoprefixer": "^10.4.19", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.29.1", @@ -67,8 +73,10 @@ "lint-staged": "^15.2.7", "node-ts": "^6.0.1", "nodemon": "^3.1.3", + "postcss": "^8.4.38", "prettier": "^3.3.2", "prettier-plugin-pug": "^1.0.0-alpha.8", + "tailwindcss": "^3.4.4", "ts-node": "^10.9.2", "typescript": "^5.0.4" } diff --git a/src/app/controllers/app.controller.ts b/src/app/controllers/app.controller.ts new file mode 100644 index 0000000..4ecb175 --- /dev/null +++ b/src/app/controllers/app.controller.ts @@ -0,0 +1,23 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Request, Response, NextFunction } from 'express'; +import ViewService from '../services/shared/view.service'; + +class AppController { + static async showHomePage( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const viewService = new ViewService(); + req.flash('error', 'Error msg sample : une erreur est survenue.'); + req.flash('success', 'Success msg sample : Successfully added.'); + viewService.renderPage(req, res, 'index'); + } catch (error) { + const viewService = new ViewService(); + viewService.renderErrorPage(req, res, 500, 'Internal Server Error'); + } + } +} + +export default AppController; diff --git a/src/app/routes/app.routes.ts b/src/app/routes/app.routes.ts new file mode 100644 index 0000000..8a313ea --- /dev/null +++ b/src/app/routes/app.routes.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import AppController from '../controllers/app.controller'; + +const router = Router(); + +router.get('/', AppController.showHomePage); + +export default router; diff --git a/src/app/routes/routes.ts b/src/app/routes/routes.ts index df2fc13..1f589c6 100644 --- a/src/app/routes/routes.ts +++ b/src/app/routes/routes.ts @@ -1,8 +1,10 @@ import { Router } from 'express'; import userRouter from './user.routes'; +import appRoutes from './app.routes'; const router = Router(); +router.use('/', appRoutes); router.use('/users', userRouter); export default router; diff --git a/src/app/services/shared/view.service.ts b/src/app/services/shared/view.service.ts new file mode 100644 index 0000000..bcb0e7c --- /dev/null +++ b/src/app/services/shared/view.service.ts @@ -0,0 +1,47 @@ +import { Application, Request, Response } from 'express'; + +class ViewService { + // constructor(app: Application) { + // this.setup(app); + // } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private setup(app: Application) { + // + } + + renderPage(req: Request, res: Response, view: string, options: any = {}) { + res.render(view, { + ...options, + successMessages: req.flash('success'), + errorMessages: req.flash('error'), + }); + } + + renderErrorPage( + req: Request, + res: Response, + statusCode: number, + message: string, + ) { + const view = `errors/${statusCode}`; + res.status(statusCode).render(view, { + message, + successMessages: req.flash('success'), + errorMessages: req.flash('error'), + }); + } + + redirectWithFlash( + req: Request, + res: Response, + route: string, + message: string, + type: 'success' | 'error', + ) { + req.flash(type, message); + res.redirect(route); + } +} + +export default ViewService; diff --git a/src/config/index.ts b/src/config/index.ts index c5cbad0..a94cb29 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -42,6 +42,11 @@ interface Config { smtpPort: number; webappPort: number; }; + session: { + secret: string; + }; + viewEngines: string[]; + defaultViewEngine: string; } const config: Config = { @@ -84,6 +89,11 @@ const config: Config = { smtpPort: parseInt(process.env.MAILDEV_SMTP || '9025', 10), webappPort: parseInt(process.env.MAILDEV_WEBAPP_PORT || '9080', 10), }, + session: { + secret: process.env.SESSION_SECRET || 'your-session-secret', + }, + viewEngines: ['ejs', 'pug', 'handlebars', 'nunjucks'], //Supported view engines + defaultViewEngine: process.env.VIEW_ENGINE || 'ejs', }; export default config; diff --git a/src/framework/session-flash/index.ts b/src/framework/session-flash/index.ts new file mode 100644 index 0000000..92ce15e --- /dev/null +++ b/src/framework/session-flash/index.ts @@ -0,0 +1,16 @@ +import { Application } from 'express'; +import session from 'express-session'; +import flash from 'connect-flash'; +import config from '../../config'; + +export const initializeSessionAndFlash = (app: Application): void => { + app.use( + session({ + secret: config.session.secret, + resave: false, + saveUninitialized: true, + cookie: { secure: config.runningProd }, + }), + ); + app.use(flash()); +}; diff --git a/src/framework/view-engine/ejs.ts b/src/framework/view-engine/ejs.ts new file mode 100644 index 0000000..4267670 --- /dev/null +++ b/src/framework/view-engine/ejs.ts @@ -0,0 +1,8 @@ +import express from 'express'; +import path from 'path'; + +export default (app: express.Application): void => { + app.set('view engine', 'ejs'); + app.set('views', path.join(__dirname, '../../../views')); + app.use(express.static(path.join(__dirname, '../../../public'))); +}; diff --git a/src/framework/view-engine/handlebars.ts b/src/framework/view-engine/handlebars.ts new file mode 100644 index 0000000..18ffc01 --- /dev/null +++ b/src/framework/view-engine/handlebars.ts @@ -0,0 +1 @@ +// TODO: add handlebars init diff --git a/src/framework/view-engine/index.ts b/src/framework/view-engine/index.ts new file mode 100644 index 0000000..ebfb1ec --- /dev/null +++ b/src/framework/view-engine/index.ts @@ -0,0 +1,23 @@ +import { Application } from 'express'; +import config from '../../config'; + +const initializeViewEngine = async (app: Application): Promise => { + const viewEngine = config.defaultViewEngine; + + if (!config.viewEngines.includes(viewEngine)) { + throw new Error( + `View engine ${viewEngine} is not supported. Please choose one of the following: ${config.viewEngines.join(', ')}.`, + ); + } + + try { + const viewEngineModule = await import(`./${viewEngine}`); + viewEngineModule.default(app); + console.log(`${viewEngine} view engine initialized.`); + } catch (error) { + console.error(`Failed to initialize ${viewEngine} view engine.`, error); + throw new Error(`View engine ${viewEngine} not supported.`); + } +}; + +export default initializeViewEngine; diff --git a/src/framework/view-engine/nunjucks.ts b/src/framework/view-engine/nunjucks.ts new file mode 100644 index 0000000..26297b1 --- /dev/null +++ b/src/framework/view-engine/nunjucks.ts @@ -0,0 +1 @@ +// TODO: add nunjuck init diff --git a/src/framework/view-engine/pug.ts b/src/framework/view-engine/pug.ts new file mode 100644 index 0000000..d5a6bf8 --- /dev/null +++ b/src/framework/view-engine/pug.ts @@ -0,0 +1,8 @@ +import express from 'express'; +import path from 'path'; + +export default (app: express.Application): void => { + app.set('view engine', 'pug'); + app.set('views', path.join(__dirname, '../../../views')); + app.use(express.static(path.join(__dirname, '../../../public'))); +}; diff --git a/src/framework/webserver/express.ts b/src/framework/webserver/express.ts index d956da4..35757fd 100644 --- a/src/framework/webserver/express.ts +++ b/src/framework/webserver/express.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; @@ -9,6 +10,8 @@ import config from '../../config'; import { clientAuthentication } from '../../app/utils/middlewares'; import path from 'path'; import bruteForce from '../../app/utils/middlewares/bruteforce'; +import initializeViewEngine from '../view-engine'; +import { initializeSessionAndFlash } from '../session-flash'; const app = express(); const morganEnv = config.runningProd ? 'combined' : 'dev'; @@ -45,6 +48,12 @@ app.use(morgan(morganEnv)); app.use(express.json()); app.disable('x-powered-by'); // Disable X-Powered-By header +// Initialize Session and Flash +initializeSessionAndFlash(app); + +// Set view engine +initializeViewEngine(app); + // Apply brute force protection to login route app.post('/api/auth/login', bruteForce.prevent, (req, res) => { res.send('Login route'); @@ -53,15 +62,11 @@ app.post('/api/auth/login', bruteForce.prevent, (req, res) => { // Client authentication middleware app.use(clientAuthentication); -// Serve the presentation.html file -app.get('/', (req, res) => { - res.sendFile( - path.join(__dirname, '../../templates/app', 'presentation.html'), - ); -}); +// Serve routes +app.get('/', routes); // API Routes -app.use('/api', routes); +// app.use('/api', routes); // Error handlers app.use(notFoundHandler); diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..d947ab1 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,9 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [], + theme: { + extend: {}, + }, + plugins: [], +} + diff --git a/views/index.ejs b/views/index.ejs new file mode 100644 index 0000000..ca8f392 --- /dev/null +++ b/views/index.ejs @@ -0,0 +1,26 @@ + + + + + + Accueil + + +

Bienvenue sur notre plateforme

+ <% if (successMessages && successMessages.length > 0) { %> +
+ <% successMessages.forEach(function(message) { %> +

<%= message %>

+ <% }); %> +
+ <% } %> + <% if (errorMessages && errorMessages.length > 0) { %> +
+ <% errorMessages.forEach(function(message) { %> +

<%= message %>

+ <% }); %> +
+ <% } %> +

Ceci est la page d'accueil de notre plateforme.

+ + From 1882910e5fedb312355942f6b94b54f324c399e2 Mon Sep 17 00:00:00 2001 From: Abdou-Raouf ATARMLA Date: Fri, 28 Jun 2024 17:39:28 +0000 Subject: [PATCH 09/17] fix: fixed live reload --- docker-compose.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yaml b/docker-compose.yaml index 126cd6a..3fad33a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -13,6 +13,8 @@ services: - redis - minio - maildev + volumes: + - .:/usr/src/app mongo: image: mongo From 5977d5bcbfa9b1df13f140b9665abfc1150307f1 Mon Sep 17 00:00:00 2001 From: Abdou-Raouf ATARMLA Date: Sat, 29 Jun 2024 23:56:59 +0000 Subject: [PATCH 10/17] fix: Ensure Derived Services Use Specific Repository Methods - Updated BaseService to accept a generic type for the repository. - Modified derived services to use the specific generic type for the derived repository. - Ensured that the specific methods of the derived repositories are accessible via `this.repository` in the derived services. This fix resolves the issue where services were inadvertently using methods from BaseRepository instead of their specific repository methods. Fixes #5 --- src/app/repositories/base.repo.ts | 2 +- src/app/services/base.service.ts | 8 ++++---- src/app/services/user.service.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/repositories/base.repo.ts b/src/app/repositories/base.repo.ts index 116dd59..95b66f0 100644 --- a/src/app/repositories/base.repo.ts +++ b/src/app/repositories/base.repo.ts @@ -8,7 +8,7 @@ import { } from 'mongoose'; export class BaseRepository { - private model: Model; + protected model: Model; constructor(model: Model) { this.model = model; diff --git a/src/app/services/base.service.ts b/src/app/services/base.service.ts index dc2a90f..c820e7e 100644 --- a/src/app/services/base.service.ts +++ b/src/app/services/base.service.ts @@ -1,11 +1,11 @@ -import { BaseRepository } from '../repositories/base.repo'; import { Document } from 'mongoose'; +import { BaseRepository } from '../repositories/base.repo'; import ErrorResponse from '../utils/handlers/error/response'; import { SuccessResponseType, ErrorResponseType } from '../utils/types'; import { escapeRegex, slugify } from '../../helpers/string'; -export class BaseService { - protected repository: BaseRepository; +export class BaseService> { + protected repository: R; protected handleSlug: boolean; protected uniqueFields: string[]; protected populateFields: string[]; @@ -13,7 +13,7 @@ export class BaseService { protected searchFields?: string[]; constructor( - repository: BaseRepository, + repository: R, handleSlug = false, populateFields: string[] = [], ) { diff --git a/src/app/services/user.service.ts b/src/app/services/user.service.ts index a5d7c3a..add8674 100644 --- a/src/app/services/user.service.ts +++ b/src/app/services/user.service.ts @@ -10,7 +10,7 @@ import { import { BaseService } from './base.service'; import bcrypt from 'bcrypt'; -class UserService extends BaseService { +class UserService extends BaseService { constructor() { const userRepo = new UserRepository(UserModel); super(userRepo, true /*, ['profilePicture']*/); From 2d4e2ad6286b15f1a3fc9ddc39f3e23b8cf3fad6 Mon Sep 17 00:00:00 2001 From: Abdou-Raouf ATARMLA Date: Sat, 29 Jun 2024 23:57:55 +0000 Subject: [PATCH 11/17] chore: commitlint configs update to authorize footer in commit messages --- commitlint.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commitlint.config.js b/commitlint.config.js index ba0cd1b..0177d34 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -66,6 +66,6 @@ module.exports = { 'subject-empty': [2, 'never'], 'body-leading-blank': [2, 'always'], 'footer-leading-blank': [2, 'always'], - 'footer-empty': [2, 'always'], + // 'footer-empty': [2, 'always'], }, }; From 0659846b0bc62357f0c3bb20b954ce7586cee805 Mon Sep 17 00:00:00 2001 From: Abdou-Raouf ATARMLA Date: Sun, 30 Jun 2024 17:42:26 +0000 Subject: [PATCH 12/17] perf: error handling enhanced in user service - Thowing the error directly from the catch instead of returning some of them directly on the flow --- src/app/services/user.service.ts | 30 ++++++------------------------ 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/src/app/services/user.service.ts b/src/app/services/user.service.ts index add8674..e8dc4a2 100644 --- a/src/app/services/user.service.ts +++ b/src/app/services/user.service.ts @@ -26,10 +26,7 @@ class UserService extends BaseService { _id: userId, })) as SuccessResponseType; if (!response.success || !response.document) { - return { - success: false, - error: response.error, - }; + throw response.error; } const isValid = await bcrypt.compare( @@ -57,10 +54,7 @@ class UserService extends BaseService { _id: userId, })) as SuccessResponseType; if (!response.success || !response.document) { - return { - success: false, - error: response.error, - }; + throw response.error; } const hashedPassword = await bcrypt.hash( @@ -73,10 +67,7 @@ class UserService extends BaseService { )) as SuccessResponseType; if (!updateResponse.success) { - return { - success: false, - error: updateResponse.error!, - }; + throw updateResponse.error!; } return { @@ -102,10 +93,7 @@ class UserService extends BaseService { email, })) as SuccessResponseType; if (!response.success || !response.document) { - return { - success: false, - error: response.error, - }; + throw response.error; } return { @@ -131,10 +119,7 @@ class UserService extends BaseService { email, })) as SuccessResponseType; if (!response.success || !response.document) { - return { - success: false, - error: response.error, - }; + throw response.error; } const updateResponse = (await this.update( @@ -143,10 +128,7 @@ class UserService extends BaseService { )) as SuccessResponseType; if (!updateResponse.success) { - return { - success: false, - error: updateResponse.error, - }; + throw updateResponse.error; } return { From 08b452105749108af17cb5e046c19fa9907c187f Mon Sep 17 00:00:00 2001 From: Abdou-Raouf ATARMLA Date: Sun, 30 Jun 2024 20:22:03 +0000 Subject: [PATCH 13/17] perf: mail service enhanced --- src/app/services/shared/mail.service.ts | 44 +++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/src/app/services/shared/mail.service.ts b/src/app/services/shared/mail.service.ts index 5e3711e..cd40645 100644 --- a/src/app/services/shared/mail.service.ts +++ b/src/app/services/shared/mail.service.ts @@ -3,6 +3,8 @@ import handlebars from 'handlebars'; import fs from 'fs'; import path from 'path'; import config from '../../../config'; +import { ErrorResponseType, SuccessResponseType } from '../../utils/types'; +import ErrorResponse from '../../utils/handlers/error/response'; class MailService { private transporter: Transporter; @@ -37,7 +39,7 @@ class MailService { templateData?: Record; fromName?: string; fromEmail?: string; - }): Promise { + }): Promise | ErrorResponseType> { try { let htmlContent; if (htmlTemplate) { @@ -52,7 +54,9 @@ class MailService { } const mailOptions = { - from: `"${fromName || config.mail.fromName}" <${fromEmail || config.mail.from}>`, + from: `"${fromName || config.mail.fromName}" <${ + fromEmail || config.mail.from + }>`, to, subject, text, @@ -60,10 +64,44 @@ class MailService { }; await this.transporter.sendMail(mailOptions); + return { success: true }; } catch (error) { console.error('Error sending email:', error); - throw new Error('Failed to send email'); + return { + success: false, + error: new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + 'Failed to send email', + ['Please try again later.'], + error as Error, + ), + }; + } + } + + async sendOtp({ + to, + code, + purpose, + }: { + to: string; + code: string; + purpose: string; + }): Promise | ErrorResponseType> { + const otpPurpose = config.otp.purposes[purpose]; + if (!otpPurpose) { + return { + success: false, + error: new ErrorResponse('BAD_REQUEST', 'Invalid OTP purpose provided'), + }; } + + const subject = otpPurpose.title; + const text = `${otpPurpose.message} ${code}\n\nThis code is valid for ${ + config.otp.expiration / 60000 + } minutes.`; + + return await this.sendMail({ to, subject, text }); } } From 58dc5e6a920a2dc68b09a6a68426050cec597357 Mon Sep 17 00:00:00 2001 From: Abdou-Raouf ATARMLA Date: Sun, 30 Jun 2024 20:24:46 +0000 Subject: [PATCH 14/17] =?UTF-8?q?feat:=20OTP=20feature=20enabled=20?= =?UTF-8?q?=F0=9F=94=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 + src/app/controllers/otp.controller.ts | 45 ++++++++++ src/app/models/otp.model.ts | 39 ++++++++ src/app/repositories/otp.repo.ts | 59 ++++++++++++ src/app/routes/otp.routes.ts | 9 ++ src/app/routes/routes.ts | 2 + src/app/services/otp.service.ts | 123 ++++++++++++++++++++++++++ src/app/utils/handlers/error/codes.ts | 5 ++ src/app/utils/types/index.ts | 1 + src/app/utils/types/otp.ts | 17 ++++ src/config/index.ts | 70 ++++++++++++++- src/helpers/generator.ts | 8 ++ 12 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 src/app/controllers/otp.controller.ts create mode 100644 src/app/models/otp.model.ts create mode 100644 src/app/repositories/otp.repo.ts create mode 100644 src/app/routes/otp.routes.ts create mode 100644 src/app/services/otp.service.ts create mode 100644 src/app/utils/types/otp.ts create mode 100644 src/helpers/generator.ts diff --git a/.env.example b/.env.example index 83c1568..91eca14 100644 --- a/.env.example +++ b/.env.example @@ -66,3 +66,7 @@ SESSION_SESSION_SECRET="mysessionsecret" #View engine VIEW_ENGINE=ejs + +#OTP +OTP_LENGTH=6 +OTP_EXPIRATION=15 diff --git a/src/app/controllers/otp.controller.ts b/src/app/controllers/otp.controller.ts new file mode 100644 index 0000000..99cac5f --- /dev/null +++ b/src/app/controllers/otp.controller.ts @@ -0,0 +1,45 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Request, Response, NextFunction } from 'express'; +import ApiResponse from '../utils/handlers/api-reponse'; +import { ErrorResponseType } from '../utils/types'; +import OTPService from '../services/otp.service'; + +class OTPController { + static async generateOTP( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const { email, purpose } = req.body; + const response = await OTPService.generate(email, purpose); + if (response.success) { + ApiResponse.success(res, response, 201); + } else { + throw response; + } + } catch (error) { + ApiResponse.error(res, error as ErrorResponseType); + } + } + + static async validateOTP( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const { email, code, purpose } = req.body; + const response = await OTPService.validate(email, code, purpose); + if (response.success) { + ApiResponse.success(res, response); + } else { + throw response; + } + } catch (error) { + ApiResponse.error(res, error as ErrorResponseType); + } + } +} + +export default OTPController; diff --git a/src/app/models/otp.model.ts b/src/app/models/otp.model.ts new file mode 100644 index 0000000..35efbd1 --- /dev/null +++ b/src/app/models/otp.model.ts @@ -0,0 +1,39 @@ +import { Schema, model } from 'mongoose'; +import { IOTPModel } from '../utils/types/otp'; +import config from '../../config'; + +const otpSchema: Schema = new Schema( + { + code: { + type: String, + required: true, + }, + user: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + used: { + type: Boolean, + default: false, + }, + isFresh: { + type: Boolean, + default: true, + }, + expiresAt: { + type: Date, + required: true, + }, + purpose: { + type: String, + enum: Object.keys(config.otp.purposes), + required: true, + }, + }, + { timestamps: true }, +); + +const OTPModel = model('OTP', otpSchema); + +export default OTPModel; diff --git a/src/app/repositories/otp.repo.ts b/src/app/repositories/otp.repo.ts new file mode 100644 index 0000000..fe8c92f --- /dev/null +++ b/src/app/repositories/otp.repo.ts @@ -0,0 +1,59 @@ +import { Model } from 'mongoose'; +import { BaseRepository } from './base.repo'; +import { IOTPModel, TOTPPurpose } from '../utils/types/otp'; +import config from '../../config'; +import { generateRandomOTP } from '../../helpers/generator'; + +class OTPRepository extends BaseRepository { + constructor(model: Model) { + super(model); + } + + async generateCode(user: string, purpose: TOTPPurpose): Promise { + await this.invalidateOldCodes(user, purpose); + const otp = new this.model({ + code: generateRandomOTP(config.otp.length), + expiresAt: new Date(Date.now() + config.otp.expiration), + user, + purpose, + }); + return await otp.save(); + } + + async markAsUsed(otpId: string): Promise { + return await this.model + .findByIdAndUpdate(otpId, { used: true }, { new: true }) + .exec(); + } + + async isExpired(otp: IOTPModel): Promise { + return otp.expiresAt ? Date.now() > otp.expiresAt.getTime() : true; + } + + async isValid(code: string): Promise { + const otp = await this.findOne({ code, isFresh: true, used: false }); + return otp ? Date.now() <= otp.expiresAt.getTime() : false; + } + + async findValidCodeByUser( + code: string, + user: string, + purpose: TOTPPurpose, + ): Promise { + return await this.findOne({ + code, + user, + isFresh: true, + used: false, + purpose, + }); + } + + async invalidateOldCodes(user: string, purpose: TOTPPurpose): Promise { + await this.model + .updateMany({ user, used: false, purpose }, { $set: { isFresh: false } }) + .exec(); + } +} + +export default OTPRepository; diff --git a/src/app/routes/otp.routes.ts b/src/app/routes/otp.routes.ts new file mode 100644 index 0000000..aa34dc8 --- /dev/null +++ b/src/app/routes/otp.routes.ts @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import OTPController from '../controllers/otp.controller'; + +const router = Router(); + +router.post('/generate', OTPController.generateOTP); +router.post('/validate', OTPController.validateOTP); + +export default router; diff --git a/src/app/routes/routes.ts b/src/app/routes/routes.ts index 1f589c6..c5b8c35 100644 --- a/src/app/routes/routes.ts +++ b/src/app/routes/routes.ts @@ -1,10 +1,12 @@ import { Router } from 'express'; import userRouter from './user.routes'; import appRoutes from './app.routes'; +import otpRoutes from './otp.routes'; const router = Router(); router.use('/', appRoutes); router.use('/users', userRouter); +router.use('/otp', otpRoutes); export default router; diff --git a/src/app/services/otp.service.ts b/src/app/services/otp.service.ts new file mode 100644 index 0000000..2cb4c84 --- /dev/null +++ b/src/app/services/otp.service.ts @@ -0,0 +1,123 @@ +import config from '../../config'; +import OTPModel from '../models/otp.model'; +import OTPRepository from '../repositories/otp.repo'; +import ErrorResponse from '../utils/handlers/error/response'; +import { + ErrorResponseType, + IOTPModel, + SuccessResponseType, + TOTPPurpose, +} from '../utils/types'; +import UserService from './user.service'; +import { generateRandomOTP } from '../../helpers/generator'; +import { BaseService } from './base.service'; +import { IUserModel } from '../utils/types'; +import MailService from './shared/mail.service'; + +class OTPService extends BaseService { + constructor() { + const otpRepo = new OTPRepository(OTPModel); + super(otpRepo, false); + } + + async generate( + email: string, + purpose: TOTPPurpose, + ): Promise | ErrorResponseType> { + try { + const userResponse = (await UserService.findOne({ + email, + })) as SuccessResponseType; + if (!userResponse.success || !userResponse.document) { + // TODO: Customize this kind of error to override BaseService generic not found + throw userResponse.error; + } + + const user = userResponse.document; + await this.repository.invalidateOldCodes(user.id, purpose); + + const otp = await this.repository.create({ + code: generateRandomOTP(config.otp.length), + expiresAt: new Date(Date.now() + config.otp.expiration), + user: user.id, + purpose, + }); + + const mailResponse = await MailService.sendOtp({ + to: user.email, + code: otp.code, + purpose, + }); + + if (!mailResponse.success) { + throw mailResponse.error; + } + + return { success: true, document: otp }; + } catch (error) { + return { + success: false, + error: + error instanceof ErrorResponse + ? error + : new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + (error as Error).message, + ), + }; + } + } + + async validate( + email: string, + code: string, + purpose: TOTPPurpose, + ): Promise | ErrorResponseType> { + try { + const userResponse = (await UserService.findOne({ + email, + })) as SuccessResponseType; + if (!userResponse.success || !userResponse.document) { + throw new ErrorResponse('NOT_FOUND_ERROR', 'User not found.'); + } + + const user = userResponse.document; + const otpResponse = await this.repository.findValidCodeByUser( + code, + user.id, + purpose, + ); + + const invalidOtpError = new ErrorResponse( + 'UNAUTHORIZED', + 'This OTP code is invalid or has expired.', + ); + + if (!otpResponse) { + throw invalidOtpError; + } + + const otp = otpResponse; + if (await this.repository.isExpired(otp)) { + throw invalidOtpError; + } + + await this.repository.markAsUsed(otp.id); + + return { success: true }; + } catch (error) { + return { + success: false, + error: + error instanceof ErrorResponse + ? error + : new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + (error as Error).message, + ), + }; + } + } +} + +export default new OTPService(); diff --git a/src/app/utils/handlers/error/codes.ts b/src/app/utils/handlers/error/codes.ts index a7417fa..140140c 100644 --- a/src/app/utils/handlers/error/codes.ts +++ b/src/app/utils/handlers/error/codes.ts @@ -64,6 +64,11 @@ const ErrorCodes: ErrorCodes = { message: 'Internal Server Error', statusCode: 500, }, + MAIL_ERROR: { + code: 'MAIL_ERROR', + message: 'Failed to send email. Please try again later.', + statusCode: 500, + }, }; export default ErrorCodes; diff --git a/src/app/utils/types/index.ts b/src/app/utils/types/index.ts index 8e145ef..ef04cd8 100644 --- a/src/app/utils/types/index.ts +++ b/src/app/utils/types/index.ts @@ -1,2 +1,3 @@ export * from './service-response'; export * from './user'; +export * from './otp'; diff --git a/src/app/utils/types/otp.ts b/src/app/utils/types/otp.ts new file mode 100644 index 0000000..2c4392f --- /dev/null +++ b/src/app/utils/types/otp.ts @@ -0,0 +1,17 @@ +import { Document } from 'mongoose'; +import config from '../../../config'; + +export type TOTPPurpose = keyof typeof config.otp.purposes; + +export interface IOTP { + code: string; + user: string; + used: boolean; + isFresh: boolean; + expiresAt: Date; + purpose: TOTPPurpose; + createdAt?: Date; + updatedAt?: Date; +} + +export interface IOTPModel extends IOTP, Document {} diff --git a/src/config/index.ts b/src/config/index.ts index 408cde5..a1fb51d 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -61,6 +61,14 @@ interface Config { }; viewEngines: string[]; defaultViewEngine: string; + otp: { + length: number; + expiration: number; + purposes: Record< + string, + { code: string; title: string; description: string; message: string } + >; + }; } const config: Config = { @@ -136,8 +144,68 @@ const config: Config = { session: { secret: process.env.SESSION_SECRET || 'your-session-secret', }, - viewEngines: ['ejs', 'pug', 'handlebars', 'nunjucks'], //Supported view engines + viewEngines: ['ejs', 'pug', 'handlebars', 'nunjucks'], // Supported view engines defaultViewEngine: process.env.VIEW_ENGINE || 'ejs', + otp: { + length: parseInt(process.env.OTP_LENGTH || '6', 10), + expiration: parseInt(process.env.OTP_EXPIRATION || '5') * 60 * 1000, + purposes: { + ACCOUNT_VERIFICATION: { + code: 'ACCOUNT_VERIFICATION', + title: 'Account Verification OTP', + description: 'Verify your account', + message: 'Your OTP code for account verification is:', + }, + FORGOT_PASSWORD: { + code: 'FORGOT_PASSWORD', + title: 'Password Reset OTP', + description: 'Reset your password', + message: 'Your OTP code for resetting your password is:', + }, + TWO_FACTOR_AUTHENTICATION: { + code: 'TWO_FACTOR_AUTHENTICATION', + title: 'Two-Factor Authentication OTP', + description: 'Two-factor authentication', + message: 'Your OTP code for two-factor authentication is:', + }, + EMAIL_UPDATE: { + code: 'EMAIL_UPDATE', + title: 'Email Update OTP', + description: 'Update your email address', + message: 'Your OTP code for updating your email address is:', + }, + PHONE_VERIFICATION: { + code: 'PHONE_VERIFICATION', + title: 'Phone Verification OTP', + description: 'Verify your phone number', + message: 'Your OTP code for phone verification is:', + }, + TRANSACTION_CONFIRMATION: { + code: 'TRANSACTION_CONFIRMATION', + title: 'Transaction Confirmation OTP', + description: 'Confirm your transaction', + message: 'Your OTP code for transaction confirmation is:', + }, + ACCOUNT_RECOVERY: { + code: 'ACCOUNT_RECOVERY', + title: 'Account Recovery OTP', + description: 'Recover your account', + message: 'Your OTP code for account recovery is:', + }, + CHANGE_SECURITY_SETTINGS: { + code: 'CHANGE_SECURITY_SETTINGS', + title: 'Security Settings Change OTP', + description: 'Change your security settings', + message: 'Your OTP code for changing security settings is:', + }, + LOGIN_CONFIRMATION: { + code: 'LOGIN_CONFIRMATION', + title: 'Login Confirmation OTP', + description: 'Confirm your login', + message: 'Your OTP code for login confirmation is:', + }, + }, + }, }; export default config; diff --git a/src/helpers/generator.ts b/src/helpers/generator.ts new file mode 100644 index 0000000..119d2f7 --- /dev/null +++ b/src/helpers/generator.ts @@ -0,0 +1,8 @@ +export const generateRandomOTP = (length: number) => { + const digits = '0123456789'; + let OTP = ''; + for (let i = 0; i < length; i++) { + OTP += digits[Math.floor(Math.random() * 10)]; + } + return OTP; +}; From 3a574a7ccf1fe27d74a0b9a07af933e0ebf148dd Mon Sep 17 00:00:00 2001 From: Abdou-Raouf ATARMLA Date: Sun, 30 Jun 2024 20:25:43 +0000 Subject: [PATCH 15/17] perf: enhanced error catching Now possible to get the original error stacktrace --- src/app/utils/handlers/error/response.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/app/utils/handlers/error/response.ts b/src/app/utils/handlers/error/response.ts index 1422546..25f7e41 100644 --- a/src/app/utils/handlers/error/response.ts +++ b/src/app/utils/handlers/error/response.ts @@ -5,13 +5,25 @@ class ErrorResponse extends Error { public statusCode: number; public code: string; public suggestions: string[]; + public originalError?: Error; - constructor(code: string, message?: string, suggestions: string[] = []) { + constructor( + code: string, + message?: string, + suggestions: string[] = [], + originalError?: Error, + ) { const errorCode: ErrorCode = ErrorCodes[code] || ErrorCodes.GENERAL_ERROR; super(message || errorCode.message); this.code = errorCode.code; this.statusCode = errorCode.statusCode; this.suggestions = suggestions; + this.originalError = originalError; + + // Capture the stack trace of the original error if provided + if (originalError) { + this.stack = originalError.stack; + } } } From 612dbab8a387a9c1fe8755e40558730dd5a6d071 Mon Sep 17 00:00:00 2001 From: Abdou-Raouf ATARMLA Date: Sun, 30 Jun 2024 20:27:53 +0000 Subject: [PATCH 16/17] docs: comments added for better undertanding of base service search fields --- src/app/services/base.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/services/base.service.ts b/src/app/services/base.service.ts index c820e7e..8dee821 100644 --- a/src/app/services/base.service.ts +++ b/src/app/services/base.service.ts @@ -9,8 +9,8 @@ export class BaseService> { protected handleSlug: boolean; protected uniqueFields: string[]; protected populateFields: string[]; - protected allowedFilterFields?: string[]; - protected searchFields?: string[]; + protected allowedFilterFields?: string[]; /* For other probable filters */ + protected searchFields?: string[]; /* For search like keywork */ constructor( repository: R, From bc41182ad5c85a1b9db26e22e85ba50517e31667 Mon Sep 17 00:00:00 2001 From: Abdou-Raouf ATARMLA Date: Mon, 1 Jul 2024 11:55:27 +0000 Subject: [PATCH 17/17] =?UTF-8?q?feat:=20user=20authentication=20added=20?= =?UTF-8?q?=F0=9F=92=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 9 + src/app/controllers/auth.controller.ts | 163 ++++++++ src/app/controllers/user.controller.ts | 54 +++ src/app/routes/auth.routes.ts | 16 + src/app/routes/routes.ts | 2 + src/app/routes/user.routes.ts | 13 +- src/app/services/auth.service.ts | 388 ++++++++++++------ src/app/services/otp.service.ts | 4 +- src/app/services/shared/jwt.service.ts | 62 +++ .../shared/{ => mail}/mail.service.ts | 33 +- .../shared/mail/mail.service.utility.ts | 52 +++ src/app/services/user.service.ts | 2 +- src/templates/mail/welcome.html | 44 +- 13 files changed, 672 insertions(+), 170 deletions(-) create mode 100644 src/app/controllers/auth.controller.ts create mode 100644 src/app/routes/auth.routes.ts rename src/app/services/shared/{ => mail}/mail.service.ts (70%) create mode 100644 src/app/services/shared/mail/mail.service.utility.ts diff --git a/package.json b/package.json index 2cecc1d..9a40a5e 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,10 @@ "author": "Abdou-Raouf ATARMLA", "license": "ISC", "dependencies": { + "@types/connect-flash": "^0.0.40", + "@types/express-session": "^1.18.0", + "bcrypt": "^5.1.1", + "connect-flash": "^0.1.1", "cors": "^2.8.5", "dotenv": "^16.4.5", "ejs": "^3.1.10", @@ -39,7 +43,10 @@ "express-brute": "^1.0.1", "express-brute-mongo": "^1.0.0", "express-brute-redis": "^0.0.1", + "express-list-endpoints": "^7.1.0", "express-rate-limit": "^7.3.1", + "express-session": "^1.18.0", + "handlebars": "^4.7.8", "helmet": "^7.1.0", "ioredis": "^5.4.1", "joi": "^17.13.3", @@ -52,12 +59,14 @@ "devDependencies": { "@commitlint/cli": "^19.3.0", "@commitlint/config-conventional": "^19.2.2", + "@types/bcrypt": "^5.0.2", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/express-brute": "^1.0.5", "@types/express-brute-mongo": "^0.0.39", "@types/jsonwebtoken": "^9.0.6", "@types/morgan": "^1.9.9", + "@types/nodemailer": "^6.4.15", "@typescript-eslint/eslint-plugin": "^5.57.1", "@typescript-eslint/parser": "^5.57.1", "eslint": "^8.56.0", diff --git a/src/app/controllers/auth.controller.ts b/src/app/controllers/auth.controller.ts new file mode 100644 index 0000000..3332a55 --- /dev/null +++ b/src/app/controllers/auth.controller.ts @@ -0,0 +1,163 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Request, Response, NextFunction } from 'express'; +import ApiResponse from '../utils/handlers/api-reponse'; +import { ErrorResponseType } from '../utils/types'; +import AuthService from '../services/auth.service'; + +class AuthController { + static async register( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const response = await AuthService.register(req.body); + if (response.success) { + ApiResponse.success(res, response, 201); + } else { + throw response; + } + } catch (error) { + ApiResponse.error(res, error as ErrorResponseType); + } + } + + static async verifyAccount( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const response = await AuthService.verifyAccount(req.body); + if (response.success) { + ApiResponse.success(res, response); + } else { + throw response; + } + } catch (error) { + ApiResponse.error(res, error as ErrorResponseType); + } + } + + static async loginWithPassword( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const response = await AuthService.loginWithPassword(req.body); + if (response.success) { + ApiResponse.success(res, response); + } else { + throw response; + } + } catch (error) { + ApiResponse.error(res, error as ErrorResponseType); + } + } + + static async generateLoginOtp( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const response = await AuthService.generateLoginOtp(req.body.email); + if (response.success) { + ApiResponse.success(res, response); + } else { + throw response; + } + } catch (error) { + ApiResponse.error(res, error as ErrorResponseType); + } + } + + static async loginWithOtp( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const response = await AuthService.loginWithOtp(req.body); + if (response.success) { + ApiResponse.success(res, response); + } else { + throw response; + } + } catch (error) { + ApiResponse.error(res, error as ErrorResponseType); + } + } + + static async refreshToken( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const response = await AuthService.refresh(req.body.refreshToken); + if (response.success) { + ApiResponse.success(res, response); + } else { + throw response; + } + } catch (error) { + ApiResponse.error(res, error as ErrorResponseType); + } + } + + static async logout( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const { accessToken, refreshToken } = req.body; + const response = await AuthService.logout(accessToken, refreshToken); + if (response.success) { + ApiResponse.success(res, response, 202); + } else { + throw response; + } + } catch (error) { + ApiResponse.error(res, error as ErrorResponseType); + } + } + + static async forgotPassword( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const response = await AuthService.forgotPassword(req.body.email); + if (response.success) { + ApiResponse.success(res, response); + } else { + throw response; + } + } catch (error) { + ApiResponse.error(res, error as ErrorResponseType); + } + } + + static async resetPassword( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const response = await AuthService.resetPassword(req.body); + if (response.success) { + ApiResponse.success(res, response); + } else { + throw response; + } + } catch (error) { + ApiResponse.error(res, error as ErrorResponseType); + } + } +} + +export default AuthController; diff --git a/src/app/controllers/user.controller.ts b/src/app/controllers/user.controller.ts index 3c602e8..de4c9ea 100644 --- a/src/app/controllers/user.controller.ts +++ b/src/app/controllers/user.controller.ts @@ -38,6 +38,60 @@ class UserController { ApiResponse.error(res, error as ErrorResponseType); } } + + static async getUserById( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const userId = req.params.id; + const response = await UserService.findOne({ + _id: userId, + }); + + if (response.success) { + ApiResponse.success(res, response); + } else { + throw response; + } + } catch (error) { + ApiResponse.error(res, error as ErrorResponseType); + } + } } export default UserController; + +/** + * static async createUser( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const response = (await UserService.create( + req.body, + )) as SuccessResponseType; + if (response.success) { + // Send email to the newly created user + try { + await mailService.sendMail({ + to: response.document.email, + subject: 'Welcome to Our Service', + text: 'Your account has been successfully created. Welcome aboard!', + }); + console.log('Welcome email sent successfully'); + } catch (emailError) { + console.error('Error sending welcome email:', emailError); + } + + ApiResponse.success(res, response, 201); + } else { + throw response; + } + } catch (error) { + ApiResponse.error(res, error as ErrorResponseType); + } + } + */ diff --git a/src/app/routes/auth.routes.ts b/src/app/routes/auth.routes.ts new file mode 100644 index 0000000..5571c4e --- /dev/null +++ b/src/app/routes/auth.routes.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import AuthController from '../controllers/auth.controller'; + +const router = Router(); + +router.post('/register', AuthController.register); +router.post('/verify-account', AuthController.verifyAccount); +router.post('/login', AuthController.loginWithPassword); +router.post('/generate-login-otp', AuthController.generateLoginOtp); +router.post('/login-with-otp', AuthController.loginWithOtp); +router.post('/refresh-token', AuthController.refreshToken); +router.post('/logout', AuthController.logout); +router.post('/forgot-password', AuthController.forgotPassword); +router.patch('/reset-password', AuthController.resetPassword); + +export default router; diff --git a/src/app/routes/routes.ts b/src/app/routes/routes.ts index c5b8c35..0e949d0 100644 --- a/src/app/routes/routes.ts +++ b/src/app/routes/routes.ts @@ -2,11 +2,13 @@ import { Router } from 'express'; import userRouter from './user.routes'; import appRoutes from './app.routes'; import otpRoutes from './otp.routes'; +import authRoutes from './auth.routes'; const router = Router(); router.use('/', appRoutes); router.use('/users', userRouter); router.use('/otp', otpRoutes); +router.use('/auth', authRoutes); export default router; diff --git a/src/app/routes/user.routes.ts b/src/app/routes/user.routes.ts index 8956de1..d7225b3 100644 --- a/src/app/routes/user.routes.ts +++ b/src/app/routes/user.routes.ts @@ -3,13 +3,10 @@ import UserController from '../controllers/user.controller'; import { validate } from '../utils/middlewares/validate'; import { createUserSchema } from '../utils/validators/user'; -const userRouter = Router(); +const router = Router(); -userRouter.post('/', validate(createUserSchema), (req, res, next) => - UserController.createUser(req, res, next), -); -userRouter.get('/', (req, res, next) => - UserController.getAllUsers(req, res, next), -); +router.post('/', validate(createUserSchema), UserController.createUser); +router.get('/', UserController.getAllUsers); +router.get('/:id', UserController.getUserById); -export default userRouter; +export default router; diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts index 167af66..1319f04 100644 --- a/src/app/services/auth.service.ts +++ b/src/app/services/auth.service.ts @@ -1,42 +1,62 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import UserService from '../services/user.service'; +import OTPService from '../services/otp.service'; +import JwtService from './shared/jwt.service'; +import MailServiceUtilities from './shared/mail/mail.service.utility'; import { - SuccessResponseType, ErrorResponseType, + IOTPModel, IUserModel, + SuccessResponseType, } from '../utils/types'; import ErrorResponse from '../utils/handlers/error/response'; -import JwtService from './shared/jwt.service'; +import config from '../../config'; class AuthService { - static async register( + async register( payload: any, ): Promise | ErrorResponseType> { try { const { email } = payload; - const existingUserResponse = (await UserService.findOne({ + const userResponse = (await UserService.findOne({ email, })) as SuccessResponseType; - if (existingUserResponse.success && existingUserResponse.document) { - return { - success: false, - error: new ErrorResponse( - 'VALIDATION_ERROR', - 'The entered email is already registered.', - ), - }; + + if (userResponse.success && userResponse.document) { + throw new ErrorResponse( + 'UNIQUE_FIELD_ERROR', + 'The entered email is already registered.', + ); } const createUserResponse = (await UserService.create( payload, )) as SuccessResponseType; - if (!createUserResponse.success) { - return createUserResponse; + + if (!createUserResponse.success || !createUserResponse.document) { + throw createUserResponse.error; } - // TODO: Generate and send OTP for account verification + await MailServiceUtilities.sendAccountCreationEmail({ + to: email, + firstname: createUserResponse.document.firstname, + }); + + const otpResponse = (await OTPService.generate( + email, + config.otp.purposes.ACCOUNT_VERIFICATION.code, + )) as SuccessResponseType; - return { success: true, document: createUserResponse.document }; + if (!otpResponse.success || !otpResponse.document) { + throw otpResponse.error; + } + + return { + success: true, + document: { + user: createUserResponse.document, + otp: otpResponse.document, + }, + }; } catch (error) { return { success: false, @@ -51,37 +71,89 @@ class AuthService { } } - static async verifyAccount( + async verifyAccount( payload: any, - ): Promise | ErrorResponseType> { + ): Promise | ErrorResponseType> { try { const { email, code } = payload; - - const isVerifiedResponse = (await UserService.isVerified( + const userResponse = (await UserService.findOne({ email, - )) as SuccessResponseType; - if (isVerifiedResponse.success && isVerifiedResponse.document?.verified) { - return { - success: false, - error: new ErrorResponse( - 'VALIDATION_ERROR', - 'Account is already verified.', - ), - }; + })) as SuccessResponseType; + + if (!userResponse.success || !userResponse.document) { + throw new ErrorResponse('NOT_FOUND_ERROR', 'User not found.'); } - // TODO: Validate OTP for account verification + if (userResponse.document.verified) { + return { success: true }; // If already verified, return success without further actions + } - const verifyUserResponse = (await UserService.markAsVerified( + const validateOtpResponse = await OTPService.validate( email, - )) as SuccessResponseType; + code, + config.otp.purposes.ACCOUNT_VERIFICATION.code, + ); + + if (!validateOtpResponse.success) { + throw validateOtpResponse.error; + } + + const verifyUserResponse = await UserService.markAsVerified(email); + if (!verifyUserResponse.success) { - return verifyUserResponse; + throw verifyUserResponse.error; + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: + error instanceof ErrorResponse + ? error + : new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + (error as Error).message, + ), + }; + } + } + + async generateLoginOtp( + email: string, + ): Promise | ErrorResponseType> { + try { + const userResponse = (await UserService.findOne({ + email, + })) as SuccessResponseType; + + if (!userResponse.success || !userResponse.document) { + throw new ErrorResponse('NOT_FOUND_ERROR', 'User not found.'); + } + + const user = userResponse.document; + + if (!user.verified) { + throw new ErrorResponse('UNAUTHORIZED', 'Unverified account.'); + } + + if (!user.active) { + throw new ErrorResponse( + 'FORBIDDEN', + 'Inactive account, please contact admins.', + ); } - // TODO: Send verification success email + const otpResponse = await OTPService.generate( + email, + config.otp.purposes.LOGIN_CONFIRMATION.code, + ); - return { success: true, document: verifyUserResponse.document }; + if (!otpResponse.success) { + throw otpResponse.error; + } + + return otpResponse; } catch (error) { return { success: false, @@ -96,52 +168,41 @@ class AuthService { } } - static async login( + async loginWithPassword( payload: any, ): Promise | ErrorResponseType> { try { const { email, password } = payload; - const existingUserResponse = (await UserService.findOne({ + const userResponse = (await UserService.findOne({ email, })) as SuccessResponseType; - if (!existingUserResponse.success || !existingUserResponse.document) { - return { - success: false, - error: new ErrorResponse('UNAUTHORIZED', 'Invalid credentials.'), - }; - } - const user = existingUserResponse.document; + if (!userResponse.success || !userResponse.document) { + throw new ErrorResponse('UNAUTHORIZED', 'Invalid credentials.'); + } + const user = userResponse.document; const isValidPasswordResponse = (await UserService.isValidPassword( user.id, password, )) as SuccessResponseType<{ isValid: boolean }>; + if ( !isValidPasswordResponse.success || !isValidPasswordResponse.document?.isValid ) { - return { - success: false, - error: new ErrorResponse('UNAUTHORIZED', 'Invalid credentials.'), - }; + throw new ErrorResponse('UNAUTHORIZED', 'Invalid credentials.'); } if (!user.verified) { - return { - success: false, - error: new ErrorResponse('UNAUTHORIZED', 'Unverified account.'), - }; + throw new ErrorResponse('UNAUTHORIZED', 'Unverified account.'); } if (!user.active) { - return { - success: false, - error: new ErrorResponse( - 'FORBIDDEN', - 'Inactive account, please contact admins.', - ), - }; + throw new ErrorResponse( + 'FORBIDDEN', + 'Inactive account, please contact admins.', + ); } const accessToken = await JwtService.signAccessToken(user.id); @@ -149,7 +210,10 @@ class AuthService { return { success: true, - document: { token: { accessToken, refreshToken }, user }, + document: { + token: { access: accessToken, refresh: refreshToken }, + user, + }, }; } catch (error) { return { @@ -165,24 +229,82 @@ class AuthService { } } - static async refresh( + async loginWithOtp( + payload: any, + ): Promise | ErrorResponseType> { + try { + const { email, code } = payload; + const userResponse = (await UserService.findOne({ + email, + })) as SuccessResponseType; + + if (!userResponse.success || !userResponse.document) { + throw new ErrorResponse('UNAUTHORIZED', 'Invalid credentials.'); + } + + const user = userResponse.document; + + const validateOtpResponse = await OTPService.validate( + email, + code, + config.otp.purposes.LOGIN_CONFIRMATION.code, + ); + + if (!validateOtpResponse.success) { + throw validateOtpResponse.error; + } + + if (!user.verified) { + throw new ErrorResponse('UNAUTHORIZED', 'Unverified account.'); + } + + if (!user.active) { + throw new ErrorResponse( + 'FORBIDDEN', + 'Inactive account, please contact admins.', + ); + } + + const accessToken = await JwtService.signAccessToken(user.id); + const refreshToken = await JwtService.signRefreshToken(user.id); + + return { + success: true, + document: { + token: { access: accessToken, refresh: refreshToken }, + user, + }, + }; + } catch (error) { + return { + success: false, + error: + error instanceof ErrorResponse + ? error + : new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + (error as Error).message, + ), + }; + } + } + + async refresh( refreshToken: string, ): Promise | ErrorResponseType> { try { if (!refreshToken) { - return { - success: false, - error: new ErrorResponse('BAD_REQUEST', 'Refresh token is required.'), - }; + throw new ErrorResponse('BAD_REQUEST', 'Refresh token is required.'); } const userId = await JwtService.verifyRefreshToken(refreshToken); const accessToken = await JwtService.signAccessToken(userId); + // Refresh token change to ensure rotation const newRefreshToken = await JwtService.signRefreshToken(userId); return { success: true, - document: { token: { accessToken, refreshToken: newRefreshToken } }, + document: { token: { access: accessToken, refresh: newRefreshToken } }, }; } catch (error) { return { @@ -198,21 +320,37 @@ class AuthService { } } - static async logout( + async logout( + accessToken: string, refreshToken: string, ): Promise | ErrorResponseType> { try { - if (!refreshToken) { - return { - success: false, - error: new ErrorResponse('BAD_REQUEST', 'Refresh token is required.'), - }; + if (!refreshToken || !accessToken) { + throw new ErrorResponse( + 'BAD_REQUEST', + 'Refresh and access token are required.', + ); } - const userId = await JwtService.verifyRefreshToken(refreshToken); - await JwtService.removeFromRedis(userId); + const { userId: userIdFromRefresh } = + await JwtService.checkRefreshToken(refreshToken); + const { userId: userIdFromAccess } = + await JwtService.checkAccessToken(accessToken); - return { success: true, document: null }; + if (userIdFromRefresh !== userIdFromAccess) { + throw new ErrorResponse( + 'UNAUTHORIZED', + 'Access token does not match refresh token.', + ); + } + + // Blacklist the access token + await JwtService.blacklistToken(accessToken); + + // Remove the refresh token from Redis + await JwtService.removeFromRedis(userIdFromRefresh); + + return { success: true }; } catch (error) { return { success: false, @@ -227,49 +365,45 @@ class AuthService { } } - static async forgotPassword( + async forgotPassword( email: string, ): Promise | ErrorResponseType> { try { if (!email) { - return { - success: false, - error: new ErrorResponse('BAD_REQUEST', 'Email should be provided.'), - }; + throw new ErrorResponse('BAD_REQUEST', 'Email should be provided.'); } - const existingUserResponse = (await UserService.findOne({ + const userResponse = (await UserService.findOne({ email, })) as SuccessResponseType; - if (!existingUserResponse.success || !existingUserResponse.document) { - return { - success: false, - error: new ErrorResponse('NOT_FOUND_ERROR', 'User not found.'), - }; + + if (!userResponse.success || !userResponse.document) { + throw new ErrorResponse('NOT_FOUND_ERROR', 'User not found.'); } - const user = existingUserResponse.document; + const user = userResponse.document; if (!user.verified) { - return { - success: false, - error: new ErrorResponse('UNAUTHORIZED', 'Unverified account.'), - }; + throw new ErrorResponse('UNAUTHORIZED', 'Unverified account.'); } if (!user.active) { - return { - success: false, - error: new ErrorResponse( - 'FORBIDDEN', - 'Inactive account, please contact admins.', - ), - }; + throw new ErrorResponse( + 'FORBIDDEN', + 'Inactive account, please contact admins.', + ); } - // TODO: Generate and send OTP for password reset + const otpResponse = await OTPService.generate( + email, + config.otp.purposes.FORGOT_PASSWORD.code, + ); + + if (!otpResponse.success) { + throw otpResponse.error; + } - return { success: true, document: null }; + return { success: true }; } catch (error) { return { success: false, @@ -284,52 +418,54 @@ class AuthService { } } - static async resetPassword( + async resetPassword( payload: any, ): Promise | ErrorResponseType> { try { + // We suppose a verification about new password and confirmation password have already been done const { email, code, newPassword } = payload; - const existingUserResponse = (await UserService.findOne({ + const userResponse = (await UserService.findOne({ email, })) as SuccessResponseType; - if (!existingUserResponse.success || !existingUserResponse.document) { - return { - success: false, - error: new ErrorResponse('NOT_FOUND_ERROR', 'User not found.'), - }; + + if (!userResponse.success || !userResponse.document) { + throw new ErrorResponse('NOT_FOUND_ERROR', 'User not found.'); } - const user = existingUserResponse.document; + const user = userResponse.document; if (!user.verified) { - return { - success: false, - error: new ErrorResponse('UNAUTHORIZED', 'Unverified account.'), - }; + throw new ErrorResponse('UNAUTHORIZED', 'Unverified account.'); } if (!user.active) { - return { - success: false, - error: new ErrorResponse( - 'FORBIDDEN', - 'Inactive account, please contact admins.', - ), - }; + throw new ErrorResponse( + 'FORBIDDEN', + 'Inactive account, please contact admins.', + ); } - // TODO: Validate OTP for password reset + const validateOtpResponse = await OTPService.validate( + email, + code, + config.otp.purposes.FORGOT_PASSWORD.code, + ); - const updatePasswordResponse = (await UserService.updatePassword( + if (!validateOtpResponse.success) { + throw validateOtpResponse.error; + } + + const updatePasswordResponse = await UserService.updatePassword( user.id, newPassword, - )) as SuccessResponseType; + ); + if (!updatePasswordResponse.success) { - return updatePasswordResponse; + throw updatePasswordResponse.error; } - return { success: true, document: null }; + return { success: true }; } catch (error) { return { success: false, @@ -345,4 +481,4 @@ class AuthService { } } -export default AuthService; +export default new AuthService(); diff --git a/src/app/services/otp.service.ts b/src/app/services/otp.service.ts index 2cb4c84..63b0ffe 100644 --- a/src/app/services/otp.service.ts +++ b/src/app/services/otp.service.ts @@ -12,7 +12,7 @@ import UserService from './user.service'; import { generateRandomOTP } from '../../helpers/generator'; import { BaseService } from './base.service'; import { IUserModel } from '../utils/types'; -import MailService from './shared/mail.service'; +import MailServiceUtilities from './shared/mail/mail.service.utility'; class OTPService extends BaseService { constructor() { @@ -43,7 +43,7 @@ class OTPService extends BaseService { purpose, }); - const mailResponse = await MailService.sendOtp({ + const mailResponse = await MailServiceUtilities.sendOtp({ to: user.email, code: otp.code, purpose, diff --git a/src/app/services/shared/jwt.service.ts b/src/app/services/shared/jwt.service.ts index 06adace..c5e9d86 100644 --- a/src/app/services/shared/jwt.service.ts +++ b/src/app/services/shared/jwt.service.ts @@ -245,6 +245,68 @@ class JwtService { }); }); } + + checkAccessToken(accessToken: string): Promise<{ userId: string }> { + return new Promise((resolve, reject) => { + JWT.verify( + accessToken, + this.accessTokenSecret, + (err: any, payload: any) => { + if (err) { + const message = + err.name === 'JsonWebTokenError' ? 'Unauthorized' : err.message; + const errorResponse = new ErrorResponse('UNAUTHORIZED', message); + return reject(errorResponse); + } + + const userId = payload?.aud as string; + + resolve({ userId }); + }, + ); + }); + } + + checkRefreshToken(refreshToken: string): Promise<{ userId: string }> { + return new Promise((resolve, reject) => { + JWT.verify( + refreshToken, + this.refreshTokenSecret, + (err: any, payload: any) => { + if (err) { + const errorResponse = new ErrorResponse( + 'UNAUTHORIZED', + 'Unauthorized', + ); + return reject(errorResponse); + } + + const userId = payload?.aud as string; + + client.get(userId, (redisErr: any, result: any) => { + if (redisErr) { + console.error(redisErr.message); + const errorResponse = new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + 'Internal Server Error', + ); + return reject(errorResponse); + } + + if (refreshToken === result) { + return resolve({ userId }); + } + + const errorResponse = new ErrorResponse( + 'UNAUTHORIZED', + 'Unauthorized', + ); + return reject(errorResponse); + }); + }, + ); + }); + } } export default new JwtService(); diff --git a/src/app/services/shared/mail.service.ts b/src/app/services/shared/mail/mail.service.ts similarity index 70% rename from src/app/services/shared/mail.service.ts rename to src/app/services/shared/mail/mail.service.ts index cd40645..5b31710 100644 --- a/src/app/services/shared/mail.service.ts +++ b/src/app/services/shared/mail/mail.service.ts @@ -2,9 +2,9 @@ import nodemailer, { Transporter } from 'nodemailer'; import handlebars from 'handlebars'; import fs from 'fs'; import path from 'path'; -import config from '../../../config'; -import { ErrorResponseType, SuccessResponseType } from '../../utils/types'; -import ErrorResponse from '../../utils/handlers/error/response'; +import config from '../../../../config'; +import { ErrorResponseType, SuccessResponseType } from '../../../utils/types'; +import ErrorResponse from '../../../utils/handlers/error/response'; class MailService { private transporter: Transporter; @@ -45,7 +45,7 @@ class MailService { if (htmlTemplate) { const templatePath = path.join( __dirname, - '../../templates/mail/', + '../../../../templates/mail/', `${htmlTemplate}.html`, ); const templateSource = fs.readFileSync(templatePath, 'utf-8'); @@ -78,31 +78,6 @@ class MailService { }; } } - - async sendOtp({ - to, - code, - purpose, - }: { - to: string; - code: string; - purpose: string; - }): Promise | ErrorResponseType> { - const otpPurpose = config.otp.purposes[purpose]; - if (!otpPurpose) { - return { - success: false, - error: new ErrorResponse('BAD_REQUEST', 'Invalid OTP purpose provided'), - }; - } - - const subject = otpPurpose.title; - const text = `${otpPurpose.message} ${code}\n\nThis code is valid for ${ - config.otp.expiration / 60000 - } minutes.`; - - return await this.sendMail({ to, subject, text }); - } } export default new MailService(); diff --git a/src/app/services/shared/mail/mail.service.utility.ts b/src/app/services/shared/mail/mail.service.utility.ts new file mode 100644 index 0000000..010e865 --- /dev/null +++ b/src/app/services/shared/mail/mail.service.utility.ts @@ -0,0 +1,52 @@ +import config from '../../../../config'; +import ErrorResponse from '../../../utils/handlers/error/response'; +import { ErrorResponseType, SuccessResponseType } from '../../../utils/types'; +import MailService from './mail.service'; + +class MailServiceUtilities { + static async sendOtp({ + to, + code, + purpose, + }: { + to: string; + code: string; + purpose: string; + }): Promise | ErrorResponseType> { + const otpPurpose = config.otp.purposes[purpose]; + if (!otpPurpose) { + return { + success: false, + error: new ErrorResponse('BAD_REQUEST', 'Invalid OTP purpose provided'), + }; + } + + const subject = otpPurpose.title; + const text = `${otpPurpose.message} ${code}\n\nThis code is valid for ${ + config.otp.expiration / 60000 + } minutes.`; + + return await MailService.sendMail({ to, subject, text }); + } + + static async sendAccountCreationEmail({ + to, + firstname, + }: { + to: string; + firstname: string; + }): Promise | ErrorResponseType> { + const subject = 'Welcome to Our Service'; + const htmlTemplate = 'welcome'; + const templateData = { firstname }; + + return await MailService.sendMail({ + to, + subject, + htmlTemplate, + templateData, + }); + } +} + +export default MailServiceUtilities; diff --git a/src/app/services/user.service.ts b/src/app/services/user.service.ts index e8dc4a2..7010c12 100644 --- a/src/app/services/user.service.ts +++ b/src/app/services/user.service.ts @@ -67,7 +67,7 @@ class UserService extends BaseService { )) as SuccessResponseType; if (!updateResponse.success) { - throw updateResponse.error!; + throw updateResponse.error; } return { diff --git a/src/templates/mail/welcome.html b/src/templates/mail/welcome.html index dc63d0d..a94c082 100644 --- a/src/templates/mail/welcome.html +++ b/src/templates/mail/welcome.html @@ -1,10 +1,46 @@ - + - Welcome + + + Welcome to Our Service + -

Hello, {{name}}!

-

Welcome to our service. We are glad to have you with us.

+
+

Welcome to Our Service, {{firstname}}!

+

Your account has been successfully created. Welcome aboard!

+

+ Please verify your email address to complete the registration process. +

+

If you have any questions, feel free to contact our support team.

+ +