diff --git a/.env-example b/.env-example index 773acc4a..6b66471f 100644 --- a/.env-example +++ b/.env-example @@ -14,15 +14,9 @@ GOOGLE_SECRET_ID= GOOGLE_CALLBACK_URL= SESSION_SECRET= - -JWT_SECRET= - - -BASE_URL = < your BASE_URL> -HOST = < your Host > -SERVICE = < your SERVICE > - - -EMAIL= -PASSWORD= - +ACCESS_TOKEN_SECRET= +SENDER_NAME= +EMAIL= +PASSWORD= +ACCESS_TOKEN_SECRET= +SENDGRID_API_KEY= \ No newline at end of file diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 73e8f295..6fe2265e 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -1,5 +1,4 @@ name: build - on: push: branches: @@ -9,7 +8,6 @@ on: branches: - develop - "*" - jobs: test: runs-on: ubuntu-latest @@ -25,16 +23,19 @@ jobs: DB_HOSTED_MODE: ${{ secrets.DB_HOSTED_MODE }} ACCESS_TOKEN_SECRET: ${{ secrets.ACCESS_TOKEN_SECRET }} SESSION_SECRET: ${{ secrets.SESSION_SECRET }} - JWT_SECRET: ${{ secrets.JWT_SECRET }} CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }} SENDER_NAME: ${{ secrets.SENDER_NAME }} + GOOGLE_CALLBACK_URL: ${{ secrets.GOOGLE_CALLBACK_URL }} + GOOGLE_SECRET_ID: ${{ secrets.GOOGLE_SECRET_ID }} + DB_PROD_URL: ${{ secrets.DB_PROD_URL }} + DB_DEV_URL: ${{ secrets.DB_DEV_URL }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} strategy: matrix: node-version: ["20.x"] - steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} @@ -44,7 +45,6 @@ jobs: cache: "npm" - name: Install dependencies run: npm install - - name: Run tests run: npm run test @@ -54,14 +54,11 @@ jobs: curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter chmod +x ./cc-test-reporter ./cc-test-reporter before-build - - name: Store coverage report if: always() run: mkdir -p coverage - - name: Send coverage report to Code Climate if: always() run: ./cc-test-reporter after-build -t lcov -p coverage - - name: coveralls run: npx coveralls < coverage/lcov.info diff --git a/package-lock.json b/package-lock.json index 3ffce87d..949bb66b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4845,20 +4845,6 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", diff --git a/package.json b/package.json index 8083269b..64f7c549 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,10 @@ "/src/utils/token.validation.ts", "/src/services/mailService.ts", "/src/middlewares/passport.ts", - "/src/database/config/db.config.ts" + "/src/database/config/db.config.ts", + "/src/utils", + "src/helpers", + "src/documention/index.ts" ] }, "devDependencies": { diff --git a/src/__test__/product.test.ts b/src/__test__/product.test.ts deleted file mode 100644 index 0e5ade84..00000000 --- a/src/__test__/product.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import app from "../app"; -import request from "supertest"; -// import { connectionToDatabase } from "../database/config/db.config"; - -jest.setTimeout(30000); - -describe("PRODUCT API TEST", () => { - // beforeAll(async () => { - // await connectionToDatabase(); - // }); - - it("Seller should create a product", async () => { - // Your test implementation goes here - }); -}); diff --git a/src/__test__/users.test.ts b/src/__test__/users.test.ts index 3b7b8d5e..499aa6c9 100644 --- a/src/__test__/users.test.ts +++ b/src/__test__/users.test.ts @@ -4,6 +4,10 @@ import { connectionToDatabase } from "../database/config/db.config"; import { deleteTableData } from "../utils/database.utils"; import { User } from "../database/models/User"; import { Token } from "../database/models/token"; +import { forgotPassword } from "../controllers/resetPasswort"; +import { resetPasswort } from "../controllers/resetPasswort"; +import { Request } from "express"; + import { bad_two_factor_authentication_data, login_user, @@ -13,8 +17,13 @@ import { partial_two_factor_authentication_data, two_factor_authentication_data, user_bad_request, + requestResetBody, + newPasswordBody, + NotUserrequestBody, + sameAsOldPassword, } from "../mock/static"; import { generateAccessToken } from "../helpers/security.helpers"; +import { resetPassword } from "../database/models/resetPassword"; jest.setTimeout(30000); @@ -34,6 +43,8 @@ let token: string; const Jest_request = request(app.use(logErrors)); +let resetToken = ""; + describe("USER API TEST", () => { beforeAll(async () => { await connectionToDatabase(); @@ -43,6 +54,15 @@ describe("USER API TEST", () => { await deleteTableData(User, "users"); await deleteTableData(Token, "tokens"); }); + it("Welcome to Hacker's e-commerce backend and return 200", async () => { + const { body } = await Jest_request.get("/").expect(200); + }); + + it("should display login home page and return 200", async () => { + const { body } = await Jest_request.get("/api/v1/").expect(200); + expect(body.message).toBe("Welcome to Hacker's e-commerce backend!"); + }); + it("it should register a user and return 201", async () => { const { body } = await Jest_request.post("/api/v1/users/register") .send(NewUser) @@ -51,6 +71,7 @@ describe("USER API TEST", () => { expect(body.message).toStrictEqual( "Account Created successfully, Plase Verify your Account", ); + const tokenRecord = await Token.findOne(); token = tokenRecord?.dataValues.token ?? ""; }); @@ -68,13 +89,13 @@ describe("USER API TEST", () => { expect(body.status).toStrictEqual("CONFLICT"); expect(body.message).toStrictEqual("User already exist!"); }); + it("should verify a user's account and return 200", async () => { // Assuming you have a way to create a user and a corresponding verification token const { body } = await Jest_request.get( `/api/v1/users/account/verify/${token}`, ); - expect(body.status).toStrictEqual(200); expect(body.message).toStrictEqual("Email verified successfull"); }); @@ -92,7 +113,13 @@ describe("USER API TEST", () => { * ---------------------------- LOGIN -------------------------------------------- */ - it("should successfully login a user and return 202", async () => { + it("should successfully login a user and return 200", async () => { + await User.update( + { isVerified: true }, + { + where: { email: login_user.email }, + }, + ); const { body } = await Jest_request.post("/api/v1/users/login") .send(login_user) .expect(200); @@ -124,106 +151,230 @@ describe("USER API TEST", () => { expect(body.status).toStrictEqual("BAD REQUEST"); expect(body.message).toBeDefined(); }); -}); -/** - * -----------------------------------------LOG OUT-------------------------------------- - */ + /*** + * ---------------------------- RESET PASSWORD -------------------------------------------- + */ -it("Should log out a user and return 404", async () => { - const { body } = await Jest_request.post("/api/v1/users/logout").send(); - expect(404); - expect(body.status).toStrictEqual("NOT FOUND"); - expect(body.message).toStrictEqual("Token Not Found"); -}); + it("it should return 200 when email is sent to user resetting password", async () => { + const authenticatetoken = generateAccessToken({ + id: "1", + role: "seller", + email: NewUser.email, + }); + await resetPassword.create({ + resetToken: authenticatetoken, + email: NewUser.email, + }); + const { body } = await Jest_request.post("/api/v1/users/forgot-password") + .send({ email: NewUser.email }) + .expect(200); + resetToken = authenticatetoken; + expect(body.status).toStrictEqual("SUCCESS"); + expect(body.message).toStrictEqual("Email sent successfully"); + }); -/* - * ---------------------------- TWO FACTOR AUTHENTICATION -------------------------------------------- - */ + it("it should return 404 when user requesting reset is not found in database", async () => { + const { body } = await Jest_request.post("/api/v1/users/forgot-password") + .send(NotUserrequestBody) + .expect(404); -it("should authenticate the user and return SUCCESS", async () => { - const authenticatetoken = generateAccessToken({ - id: "1", - role: "seller", - otp: two_factor_authentication_data.otp, + expect(body.message).toEqual("User not found"); }); - const { body } = await Jest_request.post( - `/api/v1/users/2fa/${authenticatetoken}`, - ) - .send(two_factor_authentication_data) - .expect(200); - expect(body.message).toStrictEqual("Account authentication successfully!"); -}); + it("it should return 400 when email is not provided", async () => { + const { body } = await Jest_request.post("/api/v1/users/forgot-password") + .send({}) + .expect(400); + }); -it("should return 401 if user add invalid otp", async () => { - const authenticatetoken = generateAccessToken({ - id: "1", - role: "seller", - otp: two_factor_authentication_data.otp, - }); - const { body } = await Jest_request.post( - `/api/v1/users/2fa/${authenticatetoken}`, - ) - .send(bad_two_factor_authentication_data) - .expect(401); - expect(body.response.message).toStrictEqual("Invalid One Time Password!!"); -}); + it("it should return 200 when password reset successfully", async () => { + expect(resetToken).toBeDefined(); + expect(resetToken).not.toEqual(""); + const tokenRecord = await resetPassword.findOne(); + const tokenn = tokenRecord?.dataValues.resetToken; -it("should return 400 if user add with character < 6 invalid otp", async () => { - const authenticatetoken = generateAccessToken({ - id: "1", - role: "seller", - otp: two_factor_authentication_data.otp, - }); - const { body } = await Jest_request.post( - `/api/v1/users/2fa/${authenticatetoken}`, - ) - .send(partial_two_factor_authentication_data) - .expect(400); - expect(body.message).toStrictEqual("OTP must be exactly 6 characters long!"); -}); + const { body } = await Jest_request.post( + `/api/v1/users/reset-password/${tokenn}`, + ) + .send(newPasswordBody) + .expect(200); + }); -it("should return 400 if user add with character < 6 invalid otp", async () => { - const authenticatetoken = generateAccessToken({ - id: "1", - role: "seller", - otp: two_factor_authentication_data.otp, - }); - const { body } = await Jest_request.post( - `/api/v1/users/2fa/${authenticatetoken}`, - ) - .send({}) - .expect(400); - expect(body.message).toStrictEqual("otp is required"); -}); + it("should return 400 if no decoded token is found", async () => { + const response = await request(app) + .post( + "/api/v1/users/reset-password/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRmYmM3NzM4LWE5YWItNDc2MC1hYzIxLWUzNTZkNGY0NDZjNyIsImVtYWlsIjoiaXphbnlpYnVrYXl2ZXR0ZTEwNUBnbWFpbC5jb20iLCJpYXQiOjE3MTQwNzcxOTksImV4cCI6MTcxNDE2MzU5OX0.wwtJXaviKcQYqmVX0LI0Yw1jG0wmBSqW4rHZA0Vh8zk", + ) + .send({ + password: "newPassword123", + }); -/** - * -----------------------------------------LOG OUT-------------------------------------- - */ + expect(404); + expect(response.body.message).toBe("Invalid link"); + }); -it("Should log out a user and return 404", async () => { - const { body } = await Jest_request.post("/api/v1/users/logout").send(); - expect(404); - expect(body.status).toStrictEqual("NOT FOUND"); - expect(body.message).toStrictEqual("Token Not Found"); -}); + it("it should return 400 when new password is the same to old password", async () => { + expect(resetToken).toBeDefined(); + expect(resetToken).not.toEqual(""); + const { body } = await Jest_request.post( + `/api/v1/users/reset-password/${resetToken}`, + ) + .send(sameAsOldPassword) + .expect(400); + }); -it("Should log out a user and return 201", async () => { - const { body } = await Jest_request.post("/api/v1/users/logout") - .send() - .set("Authorization", `Bearer ${token}`); - expect(201); - expect(body.status).toStrictEqual("CREATED"); - expect(body.message).toStrictEqual("Logged out successfully"); - token = token; -}); + it("it should return 400 when invalid link is provided", async () => { + const { body } = await Jest_request.post( + `/api/v1/users/reset-password/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRmYmM3NzM4LWE5YWItNDc2MC1hYzIxLWUzNTZkNGY0NDZjNyIsImVtYWlsIjoiaXphbnlpYnVrYXl2ZXR0ZTEwNUBnbWFpbC5jb20iLCJpYXQiOjE3MTQwNzcxOTksImV4cCI6MTcxNDE2MzU5OX0.wwtJXaviKcQYqmVX0LI0Yw1jG0wmBSqW4rHZA0Vh8zk`, + ) + .send(newPasswordBody) + .expect(400); + }); + + it("should return 400 when no token is provided", async () => { + const { body } = await Jest_request.post("/api/v1/users/reset-password/") + .send(newPasswordBody) + .expect(404); + }); + + jest.mock("../helpers/nodemailer", () => ({ + sendEmail: jest.fn(), + })); + + it("should send an email with the correct mailOptions", async () => { + const req: any = { + body: { email: "test@example.com" }, + } as any; + + const res: any = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await forgotPassword(req, res); + }); + + it("should return 500 if token is missing or invalid", async () => { + const req: any = {}; + + const res: any = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + await resetPasswort(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + }); + + jest.mock("../helpers/nodemailer", () => ({ + sendEmail: jest.fn(), + })); + + it("should send an email with the correct mailOptions", async () => { + const req: any = { + body: { email: "test@example.com" }, + } as any; + + const res: any = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await forgotPassword(req, res); + }); + + /* + * ---------------------------- TWO FACTOR AUTHENTICATION -------------------------------------------- + */ + + it("should authenticate the user and return SUCCESS", async () => { + const authenticatetoken = generateAccessToken({ + id: "1", + role: "seller", + otp: two_factor_authentication_data.otp, + }); + const { body } = await Jest_request.post( + `/api/v1/users/2fa/${authenticatetoken}`, + ) + .send(two_factor_authentication_data) + .expect(200); + + expect(body.message).toStrictEqual("Account authentication successfully!"); + }); + + it("should return 401 if user add invalid otp", async () => { + const authenticatetoken = generateAccessToken({ + id: "1", + role: "seller", + otp: two_factor_authentication_data.otp, + }); + const { body } = await Jest_request.post( + `/api/v1/users/2fa/${authenticatetoken}`, + ) + .send(bad_two_factor_authentication_data) + .expect(401); + expect(body.response.message).toStrictEqual("Invalid One Time Password!!"); + }); + + it("should return 400 if user add with character < 6 invalid otp", async () => { + const authenticatetoken = generateAccessToken({ + id: "1", + role: "seller", + otp: two_factor_authentication_data.otp, + }); + const { body } = await Jest_request.post( + `/api/v1/users/2fa/${authenticatetoken}`, + ) + .send(partial_two_factor_authentication_data) + .expect(400); + expect(body.message).toStrictEqual( + "OTP must be exactly 6 characters long!", + ); + }); + + it("should return 400 if user add with character < 6 invalid otp", async () => { + const authenticatetoken = generateAccessToken({ + id: "1", + role: "seller", + otp: two_factor_authentication_data.otp, + }); + const { body } = await Jest_request.post( + `/api/v1/users/2fa/${authenticatetoken}`, + ) + .send({}) + .expect(400); + expect(body.message).toStrictEqual("otp is required"); + }); -it("Should alert an error and return 401", async () => { - const { body } = await Jest_request.post("/api/v1/users/logout") - .send() - .set("Authorization", `Bearer ${token}`); - expect(401); - expect(body.status).toStrictEqual("UNAUTHORIZED"); - expect(body.message).toStrictEqual("Already logged out"); + /** + * -----------------------------------------LOG OUT-------------------------------------- + */ + + it("Should log out a user and return 404", async () => { + const { body } = await Jest_request.post("/api/v1/users/logout").send(); + expect(404); + expect(body.status).toStrictEqual("NOT FOUND"); + expect(body.message).toStrictEqual("Token Not Found"); + }); + + it("Should log out a user and return 201", async () => { + const { body } = await Jest_request.post("/api/v1/users/logout") + .send() + .set("Authorization", `Bearer ${token}`); + expect(201); + expect(body.status).toStrictEqual("CREATED"); + expect(body.message).toStrictEqual("Logged out successfully"); + token = token; + }); + + it("Should alert an error and return 401", async () => { + const { body } = await Jest_request.post("/api/v1/users/logout") + .send() + .set("Authorization", `Bearer ${token}`); + expect(401); + expect(body.status).toStrictEqual("UNAUTHORIZED"); + expect(body.message).toStrictEqual("Already logged out"); + }); }); diff --git a/src/controllers/resetPasswort.ts b/src/controllers/resetPasswort.ts new file mode 100644 index 00000000..54e0ad1d --- /dev/null +++ b/src/controllers/resetPasswort.ts @@ -0,0 +1,108 @@ +import { Request, Response } from "express"; +import { User } from "../database/models/User"; +import { ACCESS_TOKEN_SECRET, PORT } from "../utils/keys"; +import { generateAccessToken } from "../helpers/security.helpers"; +import { resetPassword } from "../database/models/resetPassword"; +import Jwt from "jsonwebtoken"; +import { sendEmail } from "../helpers/nodemailer"; +import { hashPassword } from "../utils/password"; +import { resetTokenData } from "../helpers/security.helpers"; +import { isValidPassword } from "../utils/password.checks"; + +export const forgotPassword = async (req: Request, res: Response) => { + try { + const { email } = req.body; + + const isUserExist: User | null = await User.findOne({ + where: { email: email }, + }); + + if (!isUserExist) { + return res.status(404).json({ + message: "User not found", + }); + } + + const resetToken = generateAccessToken({ + id: isUserExist?.dataValues.id, + role: isUserExist?.dataValues.role, + email: isUserExist?.dataValues.email, + }); + + await resetPassword.destroy({ + where: { email: email }, + }); + + await resetPassword.create({ + resetToken: resetToken, + email: email, + }); + + const host = process.env.BASE_URL || `http://localhost:${PORT}`; + const confirmlink: string = `${host}/passwordReset?token=${resetToken}`; + + const mailOptions = { + to: email, + subject: "Reset Password", + html: ` +

Click here to reset your password

+ `, + }; + + await sendEmail(mailOptions); + + res + .status(200) + .json({ message: "Email sent successfully", status: "SUCCESS" }); + } catch (error) { + res + .status(500) + .json({ message: "An error occurred while requesting password reset." }); + } +}; + +export const resetPasswort = async (req: Request, res: Response) => { + try { + const { password } = req.body!; + const { token } = req.params; + + const tokenAvailability = await resetPassword.findOne({ + where: { resetToken: token }, + }); + + if (!tokenAvailability) { + return res.status(400).json({ message: "Invalid link" }); + } + + const decoded = Jwt.verify(token, ACCESS_TOKEN_SECRET!) as resetTokenData; + + if (!decoded || !decoded.id) { + return res.status(404).json({ + message: "Invalid link", + }); + } + const resettingUser = await User.findOne({ where: { id: decoded.id! } }); + + const sameAsOldPassword = await isValidPassword( + password, + resettingUser?.dataValues.password as string, + ); + + if (sameAsOldPassword) { + return res + .status(400) + .json({ message: "Password cannot be the same as the old password" }); + } + + const hashedPassword: string = (await hashPassword(password)) as string; + + await resettingUser?.update({ password: hashedPassword }); + + await resetPassword.destroy({ where: { resetToken: token } }); + res.status(200).json({ message: "Password reset successfully" }); + } catch (error) { + res + .status(500) + .json({ message: "An error occurred while resetting password." }); + } +}; diff --git a/src/controllers/userController.ts b/src/controllers/userController.ts index 536ef828..a68639cb 100644 --- a/src/controllers/userController.ts +++ b/src/controllers/userController.ts @@ -1,18 +1,19 @@ import { NextFunction, Request, Response } from "express"; import { User, UserModelAttributes } from "../database/models/User"; -import { validateToken } from "../utils/token.validation"; -import { JWT_SECRET } from "../utils/keys"; import { TokenData, generateAccessToken, verifyAccessToken, } from "../helpers/security.helpers"; import { HttpException } from "../utils/http.exception"; -import passport from "../middlewares/passport"; import randomatic from "randomatic"; import HTML_TEMPLATE from "../utils/mail-template"; import { Token } from "../database/models/token"; -import { senderEmail } from "../services/mailService"; +import passport from "../middlewares/passport"; +// import sendEmail from "../utils/email"; +import { validateToken } from "../utils/token.validation"; +import { ACCESS_TOKEN_SECRET } from "../utils/keys"; +import { sendEmail } from "../helpers/nodemailer"; interface InfoAttribute { message: string; @@ -36,8 +37,9 @@ const registerUser = async ( req.login(user, async () => { const token = generateAccessToken({ id: user.id, role: user.role }); await Token.create({ token }); + const message = `${process.env.BASE_URL}/users/account/verify/${token}`; - await senderEmail({ + await sendEmail({ to: user.email, subject: "Verify Email", html: message, @@ -112,7 +114,7 @@ const login = async (req: Request, res: Response, next: NextFunction) => { }; Token.create({ token: authenticationtoken }); - senderEmail(options); + sendEmail(options); const response = new HttpException( "ACCEPTED", @@ -136,11 +138,15 @@ const login = async (req: Request, res: Response, next: NextFunction) => { const accountVerify = async (req: Request, res: Response) => { try { const token = await Token.findOne({ where: { token: req.params.token } }); + if (!token) { return res.status(400).json({ status: 400, message: "Invalid link" }); } - const { user } = validateToken(token.dataValues.token, JWT_SECRET || ""); + const { user } = validateToken( + token.dataValues.token, + ACCESS_TOKEN_SECRET as string, + ); if (!user) { return res.status(400).json({ status: 400, message: "Invalid link" }); } diff --git a/src/database/config/db.config.ts b/src/database/config/db.config.ts index a8c9aeed..7aa85c4a 100644 --- a/src/database/config/db.config.ts +++ b/src/database/config/db.config.ts @@ -4,32 +4,30 @@ import { Sequelize } from "sequelize"; config(); let db_uri: string = ""; -const APP_MODE: string = (process.env.DEV_MODE as string) || "development"; -const DB_HOST_MODE: string = process.env.DB_HOSTED_MODE as string; - -let dialect_option: any; +const APP_MODE: string = process.env.DEV_MODE || "development"; +const DB_HOST_MODE: string = process.env.DB_HOSTED_MODE || "local"; switch (APP_MODE) { case "test": - db_uri = process.env.DB_TEST_URL as string; + db_uri = process.env.DB_TEST_URL || ""; break; - case "production": - db_uri = process.env.DB_PROD_URL as string; + db_uri = process.env.DB_PROD_URL || ""; break; default: - db_uri = process.env.DB_DEV_URL as string; + db_uri = process.env.DB_DEV_URL || ""; break; } -DB_HOST_MODE === "local" - ? (dialect_option = {}) - : (dialect_option = { +const isLocal = DB_HOST_MODE === "local"; +const dialect_option = isLocal + ? {} + : { ssl: { - require: process.env.SSL, + require: true, // Adjust based on your needs rejectUnauthorized: true, }, - }); + }; export const sequelizeConnection: Sequelize = new Sequelize(db_uri, { dialect: "postgres", diff --git a/src/database/migrations/20240429163608-reset-password-tokens.js b/src/database/migrations/20240429163608-reset-password-tokens.js new file mode 100644 index 00000000..5d467d2d --- /dev/null +++ b/src/database/migrations/20240429163608-reset-password-tokens.js @@ -0,0 +1,35 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable("resetPassword_tokens", { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false, + }, + email: { + type: Sequelize.STRING, + allowNull: false, + }, + resetToken: { + type: Sequelize.STRING(1000), + allowNull: false, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + }, + }); + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async down(queryInterface, Sequelize) { + await queryInterface.dropTable("resetPassword_tokens"); + }, +}; diff --git a/src/database/models/resetPassword.ts b/src/database/models/resetPassword.ts new file mode 100644 index 00000000..9a6c9324 --- /dev/null +++ b/src/database/models/resetPassword.ts @@ -0,0 +1,33 @@ +import { DataTypes, Model } from "sequelize"; +import { sequelizeConnection } from "../config/db.config"; + +export interface resetPasswordModelAtributes { + id?: string; + email: string; + resetToken: string; +} + +export class resetPassword extends Model {} + +resetPassword.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false, + }, + email: { + type: DataTypes.STRING, + allowNull: false, + }, + resetToken: { + type: DataTypes.STRING(1000), + allowNull: false, + }, + }, + { + sequelize: sequelizeConnection, + tableName: "resetPassword_tokens", + }, +); diff --git a/src/documention/user/index.ts b/src/documention/user/index.ts index 69c2068b..122a1b50 100644 --- a/src/documention/user/index.ts +++ b/src/documention/user/index.ts @@ -133,6 +133,64 @@ const users = { tags: ["User"], security: [{ JWT: [] }], summary: "Log out a user", + }, + }, + + "/users/forgot-password": { + post: { + tags: ["User"], + // security: [{ JWT: [] }], + summary: "Request password reset", + parameters: [ + { + in: "body", + name: "request body", + required: true, + schema: { + type: "object", + properties: { + email: { + type: "string", + example: "email@example.com", + }, + }, + }, + }, + ], + consumes: ["application/json"], + responses, + }, + }, + "/users/reset-password/{token}": { + post: { + tags: ["User"], + // security: [{ JWT: [] }], + summary: "Reset password", + parameters: [ + { + in: "path", + name: "token", + required: true, + schema: { + type: "string", + }, + description: "The reset password token", + }, + { + in: "body", + name: "request body", + required: true, + schema: { + type: "object", + properties: { + password: { + type: "string", + example: "Password@123", + }, + }, + }, + }, + ], consumes: ["application/json"], }, }, diff --git a/src/helpers/nodemailer.ts b/src/helpers/nodemailer.ts new file mode 100644 index 00000000..458b7663 --- /dev/null +++ b/src/helpers/nodemailer.ts @@ -0,0 +1,35 @@ +import nodemailer from "nodemailer"; +import { EMAIL, PASSWORD, SENDER_NAME } from "../utils/keys"; + +interface MailOptions { + to: string; + subject: string; + html: any; +} + +const sender = nodemailer.createTransport({ + service: "gmail", + auth: { + user: EMAIL, + pass: PASSWORD, + }, + tls: { + rejectUnauthorized: false, + }, +}); + +// SEND EMAIL FUNCTION +export function sendEmail({ to, subject, html }: MailOptions) { + const mailOptions = { + from: `"${SENDER_NAME}" <${EMAIL}>`, + to, + subject, + html, + }; + + sender.sendMail(mailOptions, (error) => { + if (error) { + console.log("EMAILING USER FAILED:", error); + } + }); +} diff --git a/src/helpers/security.helpers.ts b/src/helpers/security.helpers.ts index f71113ea..a30aaee1 100644 --- a/src/helpers/security.helpers.ts +++ b/src/helpers/security.helpers.ts @@ -7,6 +7,11 @@ export interface TokenData { id: string | number; role: string; otp?: string; + email?: string; +} +export interface resetTokenData { + id: string | number; + email: string; } export const generateAccessToken = (userData: TokenData) => { diff --git a/src/middlewares/auth.ts b/src/middlewares/auth.ts index 78f74dd6..3918f5f0 100644 --- a/src/middlewares/auth.ts +++ b/src/middlewares/auth.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import jwt, { JwtPayload } from "jsonwebtoken"; -import { JWT_SECRET } from "../utils/keys"; +import { ACCESS_TOKEN_SECRET } from "../utils/keys"; import { Blacklist } from "../database/models/blacklist"; interface ExpandedRequest extends Request { @@ -28,7 +28,10 @@ export const authenticateUser = async ( } try { - const verifiedToken = jwt.verify(token, JWT_SECRET as string) as JwtPayload; + const verifiedToken = jwt.verify( + token, + ACCESS_TOKEN_SECRET as string, + ) as JwtPayload; const isInBlcaklist = await Blacklist.findOne({ where: { token } }); if (!verifiedToken) { @@ -65,7 +68,10 @@ export const isBuyer = async ( return res.status(401).json({ message: "Unauthorized" }); } try { - const decoded = jwt.verify(token, JWT_SECRET as string) as JwtPayload; + const decoded = jwt.verify( + token, + ACCESS_TOKEN_SECRET as string, + ) as JwtPayload; const isInBlcaklist = await Blacklist.findOne({ where: { token } }); if (!decoded) { @@ -96,7 +102,10 @@ export const isVendor = async ( return res.status(401).json({ message: "Unauthorized" }); } try { - const decoded = jwt.verify(token, JWT_SECRET as string) as JwtPayload; + const decoded = jwt.verify( + token, + ACCESS_TOKEN_SECRET as string, + ) as JwtPayload; const isInBlcaklist = await Blacklist.findOne({ where: { token } }); if (!decoded) { @@ -127,7 +136,10 @@ export const isAdmin = async ( return res.status(401).json({ message: "Unauthorized" }); } try { - const decoded = jwt.verify(token, JWT_SECRET as string) as JwtPayload; + const decoded = jwt.verify( + token, + ACCESS_TOKEN_SECRET as string, + ) as JwtPayload; const isInBlcaklist = await Blacklist.findOne({ where: { token } }); if (!decoded) { diff --git a/src/middlewares/user.middleware.ts b/src/middlewares/user.middleware.ts index b59977c3..3393a787 100644 --- a/src/middlewares/user.middleware.ts +++ b/src/middlewares/user.middleware.ts @@ -2,7 +2,9 @@ import { userValidate } from "../validations/user.valid"; import { NextFunction, Request, Response } from "express"; import validateLogIn from "../validations/login.validation"; +import validateReset from "../validations/reset.validation"; import { HttpException } from "../utils/http.exception"; +import validateNewPassword from "../validations/newPassword.validations"; const userValid = async (req: Request, res: Response, next: NextFunction) => { try { if (req.body) { @@ -45,7 +47,42 @@ const logInValidated = (req: Request, res: Response, next: NextFunction) => { next(); }; +const resetValidated = (req: Request, res: Response, next: NextFunction) => { + const error = validateReset(req.body); + + if (error) { + return res + .status(400) + .json( + new HttpException( + "BAD REQUEST", + error.details[0].message.replace(/\"/g, ""), + ), + ); + } + + next(); +}; +const isPassword = (req: Request, res: Response, next: NextFunction) => { + const error = validateNewPassword(req.body); + + if (error) { + return res + .status(400) + .json( + new HttpException( + "BAD REQUEST", + error.details[0].message.replace(/\"/g, ""), + ), + ); + } + + next(); +}; + export default { logInValidated, userValid, + resetValidated, + isPassword, }; diff --git a/src/mock/static.ts b/src/mock/static.ts index 3af6ee79..63548515 100644 --- a/src/mock/static.ts +++ b/src/mock/static.ts @@ -58,3 +58,17 @@ export const bad_two_factor_authentication_data = { export const partial_two_factor_authentication_data = { otp: "20420", }; +export const sameAsOldPassword = { + password: "passwordQWE123", +}; +export const requestResetBody = { + email: "peter234565@gmail.com", +}; +export const NotUserrequestBody = { + email: "peter2345@gmail.com", + forTesting: true, +}; + +export const newPasswordBody = { + password: "New@password", +}; diff --git a/src/routes/userRoutes.ts b/src/routes/userRoutes.ts index 88c4f642..fe718c2d 100644 --- a/src/routes/userRoutes.ts +++ b/src/routes/userRoutes.ts @@ -3,6 +3,7 @@ import userController from "../controllers/userController"; import userMiddleware from "../middlewares/user.middleware"; import logout from "../controllers/logoutController"; import otpIsValid from "../middlewares/otp"; +import { resetPasswort, forgotPassword } from "../controllers/resetPasswort"; const userRoutes = express.Router(); userRoutes.post( @@ -14,6 +15,16 @@ userRoutes.post( userRoutes.post("/login", userMiddleware.logInValidated, userController.login); userRoutes.post("/logout", logout); +userRoutes.post( + "/forgot-password", + userMiddleware.resetValidated, + forgotPassword, +); +userRoutes.post( + "/reset-password/:token", + userMiddleware.isPassword, + resetPasswort, +); userRoutes.get("/account/verify/:token", userController.accountVerify); userRoutes.post( diff --git a/src/services/mailService.ts b/src/services/mailService.ts deleted file mode 100644 index 94b46f2b..00000000 --- a/src/services/mailService.ts +++ /dev/null @@ -1,25 +0,0 @@ -import sgMail from "@sendgrid/mail"; -import { EMAIL, SENDER_NAME, SENDGRID_API_KEY } from "../utils/keys"; -interface MailOptions { - to: string; - subject: string; - html: any; -} -sgMail.setApiKey(`${SENDGRID_API_KEY}`); -// SEND EMAIL FUNCTION -export function senderEmail({ to, subject, html }: MailOptions) { - const mailOptions = { - from: `"${SENDER_NAME}" <${EMAIL}>`, - to, - subject, - html, - }; - sgMail - .send(mailOptions) - .then(() => { - console.log("Email sent"); - }) - .catch((error) => { - console.error(error); - }); -} diff --git a/src/utils/keys.ts b/src/utils/keys.ts index b2e0066d..801f4768 100644 --- a/src/utils/keys.ts +++ b/src/utils/keys.ts @@ -3,9 +3,10 @@ export const SESSION_SECRET = process.env.SESSION_SECRET as string; export const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID; export const GOOGLE_SECRET_ID = process.env.GOOGLE_SECRET_ID; export const GOOGLE_CALLBACK_URL = process.env.GOOGLE_CALLBACK_URL; -export const JWT_SECRET = process.env.JWT_SECRET; export const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET; export const DB_NAME_TEST = process.env.DB_NAME_TEST; -export const EMAIL = process.env.EMAIL; export const SENDER_NAME = process.env.SENDER_NAME; +export const EMAIL = process.env.EMAIL; +export const PASSWORD = process.env.PASSWORD; +export const HOST = process.env.HOST; export const SENDGRID_API_KEY = process.env.SENDGRID_API_KEY; diff --git a/src/validations/newPassword.validations.ts b/src/validations/newPassword.validations.ts new file mode 100644 index 00000000..96ab7dfc --- /dev/null +++ b/src/validations/newPassword.validations.ts @@ -0,0 +1,14 @@ +import Joi from "joi"; + +const newPasswordValidation = Joi.object({ + password: Joi.string().required().messages({ + "string.empty": "Password field can't be empty!", + }), +}).options({ allowUnknown: false }); + +const validateNewPassword = (body: any) => { + const { error } = newPasswordValidation.validate(body); + return error; +}; + +export default validateNewPassword; diff --git a/src/validations/reset.validation.ts b/src/validations/reset.validation.ts new file mode 100644 index 00000000..0f825283 --- /dev/null +++ b/src/validations/reset.validation.ts @@ -0,0 +1,15 @@ +import Joi from "joi"; + +const ResetPasswordValidation = Joi.object({ + email: Joi.string().required().email().messages({ + "string.empty": "Email field can not be empty!", + "string.email": "Invalid email!", + }), +}).options({ allowUnknown: true }); + +const validateReset = (body: any) => { + const { error } = ResetPasswordValidation.validate(body); + return error; +}; + +export default validateReset;