From 2983b64fafccf2b8d4d05930d2dd1427cb0da36a Mon Sep 17 00:00:00 2001 From: Joslyn Manzi Karenzi Date: Tue, 7 May 2024 20:25:43 +0200 Subject: [PATCH] * feat(rbac): Implement role based access control -define roles and permissions for vendors and buyers -assign roles and permissions to users during registration or profile update -enforce role-based access control throughout the application -write comprehensive unit tests [Delivers #34] * feat(rbac): integrate rbac into user registration -integrate role based access control into user registration [Delivers #34] * feat(rbac): integrate rbac into user registration -integrate role based access control into user registration [Delivers #34] --------- Co-authored-by: ambroisegithub Social Logins (#45) * squashing commits implementing routes for auth create passport callback function adding new user from Google creating new user check if user is exist in db implementing cookie session Fix error of TypeError: req.session.regenerate is not a function using Passport fix secret keys remove Google client secret keys working on facebook strategy get email from fb login and update the scope after verification save the user into db add profile image in db fixing minor bugs fix minor bugs in codes after rebasing & updating some fts link social login with userModel Addong Google client keys & FB client key into yml send confrim email after register a new user send email after register from facebook fix minor bugs * fix minor errors * remove lints errors user register register user test register user testing fix register user testing fix register user testing fix Authentication for User Added slint changes removed mocha added new features added new features Solved comflicts changed file added changes added new Test added new Test resolved test cases resolved test cases implemented two-factor authentication for enhanced security implemented two-factor authentication for enhanced security check whether the usertype is vendor to proceed with 2FA test the 2fa authentication add new tests for buyers login bug-fixes fixing bugs to remove conflicts with develop ft-password-recover-and-documentation This PR corrects some bugs on the user password recover function and add the documentation in th swagger bug-fixes fixing bugs on the recover password endpoints ft-password-rover Thi PR add a password recover by email feature, it also have a new email templates to send recovering token to email, and finally it resolve color contrast issue on the button nside the email template ft-password-recover-and-documentation This PR corrects some bugs on the user password recover function and add the documentation in th swagger bug-fixes fixing bugs on the recover password endpoints bug-fixes bug-fixes --- src/__test__/userController.test.ts | 200 +++++++++++++++++++++- src/controller/userController.ts | 85 +++++++++ src/database/models/userModel.ts | 2 +- src/docs/userRegisterDocs.ts | 187 ++++++++++++++++++++ src/emails/index.ts | 2 +- src/emails/templates/confirm.html | 9 +- src/emails/templates/confirmPassword.html | 112 ++++++++++++ src/emails/templates/reset.html | 11 +- src/routes/userRoutes.ts | 4 + 9 files changed, 606 insertions(+), 6 deletions(-) create mode 100644 src/emails/templates/confirmPassword.html diff --git a/src/__test__/userController.test.ts b/src/__test__/userController.test.ts index 1a48549d..87711c29 100644 --- a/src/__test__/userController.test.ts +++ b/src/__test__/userController.test.ts @@ -3,7 +3,11 @@ import app from '../app'; import { afterAllHook, beforeAllHook } from './testSetup'; import jwt from 'jsonwebtoken'; import dbConnection from '../database'; -import UserModel from '../database/models/userModel'; +import UserModel from '../database/models/userModel'; + +import dotenv from 'dotenv'; +dotenv.config(); + const userRepository = dbConnection.getRepository(UserModel); beforeAll(beforeAllHook); @@ -331,3 +335,197 @@ describe('User Login Tests', () => { expect(loginResponse.body.message).toBe('User Not Found'); }); }); + + describe('update user Profile', () => { + interface IUser { + id: number; + firstName: string; + lastName: string; + email: string; + password?: string; + userType?: Role; + googleId?: string; + facebookId?: string; + picture?: string; + provider?: string; + isVerified: boolean; + twoFactorCode?: number; + } + + interface Role { + id: number; + name: string; + permissions: string[]; + } + + + let user: IUser | undefined | null; + const userData = { + firstName: 'jan', + lastName: 'bosco', + email: 'bosco@gmail.com', + password: 'boscoPassword123', + }; + + beforeEach(async () => { + + await request(app).post('/api/v1/register').send(userData); + user = await userRepository.findOne({ where: { email: userData.email } }); + }); + + it('should update the user profile successfully', async () => { + if (user) { + const newUserData = { + firstName: 'NewFirstName', + lastName: 'NewLastName', + email: 'newemail@example.com', + password: 'bosco@gmail.com', + }; + + const response = await request(app) + .put(`/api/v1/updateProfile/${user?.id}`) + .send(newUserData); + expect(response.statusCode).toBe(201); + expect(response.body.message).toBe('User updated successfully'); + } + }); + + it('should return 404 when user not found', async () => { + const Id = 999; + const response = await request(app) + .put(`/api/v1/updateProfile/${Id}`) + .send(userData); + expect(response.statusCode).toBe(404); + expect(response.body.error).toBe('User not found'); + }); + + it('should return 400 when email already exists', async () => { + if (user) { + const newUserData = { + firstName: 'NewFirstName', + lastName: 'NewLastName', + email: 'newemail@example.com', + password: 'bosco@gmail.com', + }; + + const response = await request(app) + .put(`/api/v1/updateProfile/${user.id}`) + .send(newUserData); + expect(response.statusCode).toBe(400); + expect(response.body.error).toBe('Email is already taken'); + } + }); + + it('should return 400 when validation fails for user data', async () => { + if (user) { + const emptyData = {}; + + const response = await request(app) + .put(`/api/v1/updateProfile/${user.id}`) + .send(emptyData); + + expect(response.statusCode).toBe(400); + expect(response.body).toBeDefined(); + } + }); + +describe('Password Recover Tests', () => { + + it('should generate a password reset token and send an email', async () => { + const userData = { + firstName: 'Test', + lastName: 'User', + email: 'test@gmail.com', + password: 'TestPassword123', + userType: 'vendor' + }; + + // Register a user + await request(app).post('/api/v1/register').send(userData); + + // Find the registered user in the database + const recoverUser = await userRepository.findOne({ where: { email: userData.email } }); + + // Check if the user is found + if (recoverUser) { + // Generate a recover token using the user's email + + + // Send a request to the recover endpoint + const response = await request(app) + .post('/api/v1/recover') + .send({ email: recoverUser.email }); + + // Verify the response + expect(response.status).toBe(200); + expect(response.body.message).toEqual('Password reset token generated successfully'); + } else { + // Throw an error if the user is not found + throw new Error('User not found'); + } + }); + + it('should return a 404 error if the user email is not found', async () => { + const nonExistingEmail = 'nonexisting@example.com'; + + // Send a request to the recover endpoint with a non-existing email + const response = await request(app) + .post('/api/v1/recover') + .send({ email: nonExistingEmail }); + + // Verify the response + expect(response.status).toBe(404); + expect(response.body.message).toEqual('User not found'); + }); + + + it('should update user password with the provided reset token', async () => { + const newPassword = 'NewTestPassword123'; + + // Generate a user and a recover token + const userData = { + firstName: 'Test', + lastName: 'User', + email: 'test@gmail.com', + password: 'TestPassword123', + userType: 'vendor' + }; + + await request(app).post('/api/v1/register').send(userData); + + const recoverUser = await userRepository.findOne({ where: { email: userData.email } }); + + if (recoverUser) { + const recoverToken = jwt.sign({ email: recoverUser.email }, process.env.JWT_SECRET as jwt.Secret, { expiresIn: '1h' }); + + const response = await request(app) + .post(`/api/v1/recover/confirm?recoverToken=${recoverToken}`) + .send({ password: newPassword }); + + expect(response.status).toBe(200); + expect(response.body.message).toEqual('Password updated successfully'); + + const updatedUser = await userRepository.findOne({ where: { email: userData.email } }); + expect(updatedUser).toBeDefined(); + } else { + throw new Error('User not found'); + } + }); + + it('should return a 401 error for an invalid reset token', async () => { + const invalidResetToken = 'invalid-token'; + + // Send a request to the updateNewPassword endpoint with an invalid reset token + const response = await request(app) + .post('/api/v1/recover/confirm') + .query({ recoverToken: invalidResetToken }) + .send({ password: 'new-password' }); + + // Verify the response + expect(response.status).toBe(404); + expect(response.body.message).toEqual('Invalid or expired token'); + }); + +}); +}); + diff --git a/src/controller/userController.ts b/src/controller/userController.ts index 9501c44f..998ad415 100644 --- a/src/controller/userController.ts +++ b/src/controller/userController.ts @@ -198,3 +198,88 @@ export const verify2FA = errorHandler(async (req: Request, res: Response) => { }); return res.status(200).json({ token }); }); + +export const updateProfile = errorHandler(async (req: Request, res: Response) => { + const userId: number = parseInt(req.params.id); + const { firstName, lastName, email } = req.body as UpdateRrofileRequestBody; + + const user = await userRepository.findOne({ where: { id: userId } }); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + user.firstName = firstName || user.firstName; + user.lastName = lastName || user.lastName; + + + const emailExists = await userRepository.findOne({ where: { email } }); + + if (emailExists) { + return res.status(400).json({ error: 'Email is already taken' }); + } + + user.email = email; + + + const errors = await validate(user); + + if (errors.length > 0) { + return res.status(400).json({ errors }); + } + + await userRepository.save(user); + + return res.status(201).json({ message: 'User updated successfully' }); +}); + +export const recoverPassword = errorHandler(async (req: Request, res: Response) => { + const { email } = req.body as { email: string }; + + const user = await userRepository.findOne({ where: { email } }); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + // Generate a JWT token with the user's email as the payload + const recoverToken = jwt.sign({ email : user.email }, process.env.JWT_SECRET as jwt.Secret, { expiresIn: '1h' }); + + const confirmLink = `${process.env.APP_URL}/api/v1/recover/confirm?recoverToken=${recoverToken}`; + await sendEmail('confirmPassword', email, { name: user.firstName, link: confirmLink }); + + return res.status(200).json({ message: 'Password reset token generated successfully', recoverToken }); + +}); + +//password Recover Confirmation +export const updateNewPassword = errorHandler(async (req: Request, res: Response) => { + const recoverToken = req.query.recoverToken as string; + + const { password } = req.body as { password: string }; + + if (!recoverToken) { + return res.status(404).json({ message: 'Token is required' }); + } + + const decoded = jwt.verify(recoverToken, process.env.JWT_SECRET as jwt.Secret) as { + email : string; + }; + const user = await userRepository.findOne({ + where: { email: decoded.email }, + }); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + const hashedPassword : string = await bcrypt.hash(password, 10); + user.password = hashedPassword; + + await userRepository.save(user); + + return res.status(200).json({ message: 'Password updated successfully' }); + +}); + + diff --git a/src/database/models/userModel.ts b/src/database/models/userModel.ts index f71170e2..e30a4a9f 100644 --- a/src/database/models/userModel.ts +++ b/src/database/models/userModel.ts @@ -39,7 +39,7 @@ export default class UserModel { @Column({ default: 'active' }) status: 'active' | 'inactive'; - @Column({ nullable: true }) + @Column({ nullable: true }) twoFactorCode: number; constructor(user: Partial) { diff --git a/src/docs/userRegisterDocs.ts b/src/docs/userRegisterDocs.ts index 893944d4..de3365fb 100644 --- a/src/docs/userRegisterDocs.ts +++ b/src/docs/userRegisterDocs.ts @@ -169,4 +169,191 @@ * '500': * description: An error occurred while deleting the record. */ +/** + * @swagger + * /api/v1/user/updateProfile/{id}: + * put: + * summary: Update user profile + * tags: + * - User + * parameters: + * - in: path + * name: id + * required: true + * description: ID of the user to update + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * firstName: + * type: string + * description: The updated first name of the user. + * lastName: + * type: string + * description: The updated last name of the user. + * email: + * type: string + * format: email + * description: The updated email address of the user. + * oldPassword: + * type: string + * format: password + * description: The old password of the user for verification. + * newPassword: + * type: string + * format: password + * description: The new password of the user (optional). + * responses: + * '200': + * description: User profile updated successfully. + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * description: A message indicating successful profile update. + * '400': + * description: Bad request or validation errors. + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * description: Error message indicating the reason for the bad request. + * '404': + * description: Not Found - User not found. + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * description: A message indicating the user was not found. + * '500': + * description: Internal server error. + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * description: A message indicating an internal server error occurred. + */ +/** + * @swagger + * /api/v1/recover: + * post: + * summary: Recover Password + * tags: [User] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * email: + * type: string + * format: email + * responses: + * '200': + * description: Password reset link generated Successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * description: A message indicating successful email confirmation + * recoverToken: + * type: string + * description: JWT token for password recovery + * '404': + * description: Not Found - User not found + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * description: An error message indicating user not found + * '500': + * description: Internal Server Error + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * description: An error message indicating a server error + */ +/** + * @swagger + * /api/v1/recover/confirm: + * post: + * summary: Recover Password + * tags: [User] + * parameters: + * - in: query + * name: recoverToken + * schema: + * type: string + * required: true + * description: JWT token received in the Recover email + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * password: + * type: string + * format: password + * responses: + * '200': + * description: Password reset successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * description: A message indicating successful password reset + * '400': + * description: Missing Token or User Not Found + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * description: An error message indicating a missing token or user not found + * '500': + * description: Internal Server Error + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * description: An error message indicating a server error + */ diff --git a/src/emails/index.ts b/src/emails/index.ts index acd03cd7..ab42252f 100644 --- a/src/emails/index.ts +++ b/src/emails/index.ts @@ -1,7 +1,7 @@ import axios from 'axios'; import handlebars from 'handlebars'; import fs from 'fs'; -type EmailType = 'confirm' | 'reset'; +type EmailType = 'confirm' | 'reset' | 'confirmPassword'; type Data = { name: string; link: string; diff --git a/src/emails/templates/confirm.html b/src/emails/templates/confirm.html index 5b7f9853..d86485af 100644 --- a/src/emails/templates/confirm.html +++ b/src/emails/templates/confirm.html @@ -60,11 +60,18 @@ text-decoration: none; cursor: pointer; } - + .button:hover { background-color: #434190; + color: #f7fafc !important; } + .button:visited { + background-color: #434190; + color: #f7fafc !important; + } + + .link { color: #4c51bf; text-decoration: none; diff --git a/src/emails/templates/confirmPassword.html b/src/emails/templates/confirmPassword.html new file mode 100644 index 00000000..aa686841 --- /dev/null +++ b/src/emails/templates/confirmPassword.html @@ -0,0 +1,112 @@ + + + + + + + + + + +
+
+
+ Password Reset +
+

+ Hello there {{name}}, +

+

+ you have requested a password reset, Please the button bellow to recover your password. +

+ +
+

+ If you are having trouble clicking the "Recover Password" button, copy and paste the URL below into your web browser: {{link}} +

+
+
+
+ + + \ No newline at end of file diff --git a/src/emails/templates/reset.html b/src/emails/templates/reset.html index ed3548a5..4601e83a 100644 --- a/src/emails/templates/reset.html +++ b/src/emails/templates/reset.html @@ -54,17 +54,24 @@ border-radius: 4px; font-size: 14px; font-weight: bold; - color: #f7fafc; + color: #f7fafc !important; background-color: #4c51bf; text-align: center; text-decoration: none; cursor: pointer; } - + .button:hover { background-color: #434190; + color: #f7fafc !important; } + .button:visited { + background-color: #434190; + color: #f7fafc !important; + } + + .link { color: #4c51bf; text-decoration: none; diff --git a/src/routes/userRoutes.ts b/src/routes/userRoutes.ts index 5d5bca6e..6aec5219 100644 --- a/src/routes/userRoutes.ts +++ b/src/routes/userRoutes.ts @@ -4,6 +4,8 @@ import { confirmEmail, Login, verify2FA, + recoverPassword, + updateNewPassword, } from '../controller/userController'; import { @@ -31,5 +33,7 @@ userRouter.put( checkRole(['Admin']), deactivateAccount ); +userRouter.post('/recover', recoverPassword); +userRouter.post('/recover/confirm', updateNewPassword); export default userRouter;