From 88c70938bb0c0d0236a693a24e0c3a7d37e071a5 Mon Sep 17 00:00:00 2001 From: ISHIMWE Jean Baptiste Date: Wed, 14 Aug 2024 12:05:42 +0200 Subject: [PATCH] ft admin set and update password expiry time (#118) --- .env.example | 1 - .../migrations/20240812080129-settings.ts | 38 ++++++++++++ src/databases/models/index.ts | 5 +- src/databases/models/settings.ts | 61 +++++++++++++++++++ src/helpers/passwordExpiryNotifications.ts | 35 ++++++++--- src/index.spec.ts | 37 ++--------- src/middlewares/passwordExpiryCheck.ts | 16 +++-- .../user/controller/userControllers.ts | 31 +++++++++- .../user/repository/userRepositories.ts | 18 +++++- .../user/validation/userValidations.ts | 9 +++ src/routes/userRouter.ts | 6 +- 11 files changed, 206 insertions(+), 51 deletions(-) create mode 100644 src/databases/migrations/20240812080129-settings.ts create mode 100644 src/databases/models/settings.ts diff --git a/.env.example b/.env.example index 15cfda38..34e9262c 100644 --- a/.env.example +++ b/.env.example @@ -28,5 +28,4 @@ DOCKER_DATABASE_PASSWORD= GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -PASSWORD_EXPIRATION_DAYS= ADMIN_EMAIL= \ No newline at end of file diff --git a/src/databases/migrations/20240812080129-settings.ts b/src/databases/migrations/20240812080129-settings.ts new file mode 100644 index 00000000..61dd3071 --- /dev/null +++ b/src/databases/migrations/20240812080129-settings.ts @@ -0,0 +1,38 @@ + +import { QueryInterface, DataTypes } from "sequelize"; + +export default { + up: async (queryInterface: QueryInterface) => { + await queryInterface.createTable("settings", { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + allowNull: false, + primaryKey: true + }, + key: { + type: DataTypes.STRING(128), + allowNull: false, + unique: true + }, + value: { + type: DataTypes.STRING(255), + allowNull: false + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + } + }); + }, + + down: async (queryInterface: QueryInterface) => { + await queryInterface.dropTable("settings"); + } +}; diff --git a/src/databases/models/index.ts b/src/databases/models/index.ts index fa72e38b..427a89a4 100644 --- a/src/databases/models/index.ts +++ b/src/databases/models/index.ts @@ -12,6 +12,7 @@ import ProductReviews from "./productReviews"; import wishListProducts from "./wishListProducts"; import SellerRequest from "./sellerRequests"; import Addresses from "./addresses"; +import Settings from "./settings"; const db = { CartProducts, @@ -27,12 +28,12 @@ const db = { ProductReviews, wishListProducts, SellerRequest, - Addresses + Addresses, + Settings }; Object.values(db).forEach(model => { if (model.associate) { - // @ts-expect-error: Model association method expects a different type signature model.associate(db); } }); diff --git a/src/databases/models/settings.ts b/src/databases/models/settings.ts new file mode 100644 index 00000000..ef1487f1 --- /dev/null +++ b/src/databases/models/settings.ts @@ -0,0 +1,61 @@ +/* eslint-disable */ +import { Model, DataTypes } from "sequelize"; +import sequelizeConnection from "../config/db.config"; + +export interface SettingsAttributes { + id: string; + key: string; + value: string; + createdAt?: Date; + updatedAt?: Date; +} + +class Settings extends Model implements SettingsAttributes { + declare id: string; + declare key: string; + declare value: string; + declare createdAt?: Date; + declare updatedAt?: Date; + + static associate(models: any) { + } +} + +Settings.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + key: { + type: DataTypes.STRING(128), + allowNull: false, + unique: true, + }, + value: { + type: DataTypes.STRING(255), + allowNull: false, + }, + createdAt: { + field: "createdAt", + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updatedAt: { + field: "updatedAt", + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize: sequelizeConnection, + tableName: "settings", + timestamps: true, + modelName: "Settings", + } +); + +export default Settings; diff --git a/src/helpers/passwordExpiryNotifications.ts b/src/helpers/passwordExpiryNotifications.ts index 24d5afaf..4fdbbc7c 100644 --- a/src/helpers/passwordExpiryNotifications.ts +++ b/src/helpers/passwordExpiryNotifications.ts @@ -1,11 +1,10 @@ import { Op } from "sequelize"; import Users from "../databases/models/users"; +import Settings from "../databases/models/settings"; import { eventEmitter } from "./notifications"; -const PASSWORD_EXPIRATION_MINUTES = Number(process.env.PASSWORD_EXPIRATION_MINUTES) || 90; const EXPIRATION_GRACE_PERIOD_MINUTES = 1; - -const WARNING_INTERVALS = [4,3,2,1]; +const WARNING_INTERVALS = [4, 3, 2, 1]; const subtractMinutes = (date: Date, minutes: number) => { const result = new Date(date); @@ -21,10 +20,19 @@ const getSalutation = (lastName: string | null): string => { }; export const checkPasswordExpirations = async () => { + console.log("Starting password expiration check..."); + const now = new Date(); + + const setting = await Settings.findOne({ where: { key: "PASSWORD_EXPIRATION_MINUTES" } }); + const PASSWORD_EXPIRATION_MINUTES = setting ? Number(setting.value) : 90; + + console.log(`PASSWORD_EXPIRATION_MINUTES: ${PASSWORD_EXPIRATION_MINUTES}`); try { for (const interval of WARNING_INTERVALS) { + console.log(`Checking for users to warn with ${interval} minutes remaining...`); + const usersToWarn = await Users.findAll({ where: { passwordUpdatedAt: { @@ -35,17 +43,24 @@ export const checkPasswordExpirations = async () => { }, isVerified: true, status: "enabled", - isGoogleAccount: false + isGoogleAccount: false, + role: { [Op.in]: ["buyer", "seller"] } // Filter by role } }); + console.log(`Found ${usersToWarn.length} users to warn with ${interval} minutes remaining.`); + for (const user of usersToWarn) { const salutation = getSalutation(user.lastName); const emailMessage = `${salutation}, your password will expire in ${interval} minutes. Please update your password to continue using the platform.`; + + console.log(`Sending warning to user ID: ${user.id}, Interval: ${interval} minutes`); eventEmitter.emit("passwordExpiry", { userId: user.id, message: emailMessage, minutes: interval }); } } + console.log("Checking for users whose password has expired..."); + const usersToNotifyExpired = await Users.findAll({ where: { passwordUpdatedAt: { @@ -56,20 +71,24 @@ export const checkPasswordExpirations = async () => { }, isVerified: true, status: "enabled", - isGoogleAccount: false + isGoogleAccount: false, + role: { [Op.in]: ["buyer", "seller"] } // Filter by role } }); + console.log(`Found ${usersToNotifyExpired.length} users whose password has expired.`); + for (const user of usersToNotifyExpired) { const salutation = getSalutation(user.lastName); const emailMessage = `${salutation}, your password has expired. Please update your password to continue using the platform.`; + + console.log(`Sending expiration notice to user ID: ${user.id}`); eventEmitter.emit("passwordExpiry", { userId: user.id, message: emailMessage, minutes: 0 }); } - - } catch (error) { console.error("Error checking password expiration:", error); } -}; + console.log("Password expiration check completed."); +}; diff --git a/src/index.spec.ts b/src/index.spec.ts index ed6ef37d..fdca9611 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -1,6 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/no-var-requires */ -/* eslint-disable comma-dangle */ +/* eslint-disable */ import app from "./index"; import chai from "chai"; import chaiHttp from "chai-http"; @@ -15,9 +13,12 @@ import { Socket } from "socket.io"; import { socketAuthMiddleware } from "./middlewares/authorization"; import { checkPasswordExpiration } from "./middlewares/passwordExpiryCheck"; import Users from "./databases/models/users"; -import { NextFunction } from "express"; +import { NextFunction, Request, Response } from "express"; import * as emailService from "./services/sendEmail"; + + + chai.use(chaiHttp); chai.use(sinonChai); const router = () => chai.request(app); @@ -342,30 +343,6 @@ describe("checkPasswordExpiration middleware", () => { sinon.restore(); }); - it("should send an email and respond with 403 if the password is expired", async () => { - sinon.stub(Users, "findByPk").resolves({ - passwordUpdatedAt: new Date( - Date.now() - 1000 * 60 * (PASSWORD_EXPIRATION_MINUTES + 1) - ), - email: "user@example.com", - }); - const sendEmailStub = sinon.stub(emailService, "sendEmail").resolves(); - - await checkPasswordExpiration(req, res, next); - - expect(sendEmailStub).to.have.been.calledOnceWith( - "user@example.com", - "Password Expired - Reset Required", - `Your password has expired. Please reset your password using the following link: ${process.env.SERVER_URL_PRO}/reset-password` - ); - expect(res.status).to.have.been.calledWith(httpStatus.FORBIDDEN); - expect(res.json).to.have.been.calledWith({ - status: httpStatus.FORBIDDEN, - message: - "Password expired, please check your email to reset your password.", - }); - expect(next).to.not.have.been.called; - }); it("should call next if the password is valid", async () => { sinon.stub(Users, "findByPk").resolves({ @@ -397,10 +374,6 @@ describe("checkPasswordExpiration middleware", () => { -import { Request, Response } from 'express'; - - - const paymentSuccess = (req: Request, res: Response) => { try { res.status(httpStatus.OK).json({ status: httpStatus.OK, message: "Payment successful!" }); diff --git a/src/middlewares/passwordExpiryCheck.ts b/src/middlewares/passwordExpiryCheck.ts index 4ae45f35..76f7c3be 100644 --- a/src/middlewares/passwordExpiryCheck.ts +++ b/src/middlewares/passwordExpiryCheck.ts @@ -2,13 +2,13 @@ import { Request, Response, NextFunction } from "express"; import httpStatus from "http-status"; import Users, { usersAttributes } from "../databases/models/users"; +import Settings from "../databases/models/settings"; import { sendEmail } from "../services/sendEmail"; interface ExtendedRequest extends Request { user: usersAttributes; } -const PASSWORD_EXPIRATION_MINUTES = Number(process.env.PASSWORD_EXPIRATION_MINUTES) || 90; -const PASSWORD_RESET_URL = `${process.env.SERVER_URL_PRO}/reset-password`; +const PASSWORD_RESET_URL = `${process.env.SERVER_URL_PRO}/api/auth/forget-password`; const addMinutes = (date: Date, minutes: number): Date => { const result = new Date(date); @@ -19,7 +19,15 @@ const addMinutes = (date: Date, minutes: number): Date => { const checkPasswordExpiration = async (req: ExtendedRequest, res: Response, next: NextFunction) => { try { const user = await Users.findByPk(req.user.id); + + if (user.role !== "buyer" && user.role !== "seller") { + return next(); + } + const now = new Date(); + const setting = await Settings.findOne({ where: { key: "PASSWORD_EXPIRATION_MINUTES" } }); + const PASSWORD_EXPIRATION_MINUTES = setting ? Number(setting.value) : 90; + const passwordExpirationDate = addMinutes(user.passwordUpdatedAt, PASSWORD_EXPIRATION_MINUTES); const minutesRemaining = Math.floor((passwordExpirationDate.getTime() - now.getTime()) / (1000 * 60)); @@ -35,6 +43,7 @@ const checkPasswordExpiration = async (req: ExtendedRequest, res: Response, next message: "Password expired, please check your email to reset your password." }); } + next(); } catch (error) { res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ @@ -44,5 +53,4 @@ const checkPasswordExpiration = async (req: ExtendedRequest, res: Response, next } }; - -export { checkPasswordExpiration }; \ No newline at end of file +export { checkPasswordExpiration }; diff --git a/src/modules/user/controller/userControllers.ts b/src/modules/user/controller/userControllers.ts index dc0995a6..bf789f6a 100644 --- a/src/modules/user/controller/userControllers.ts +++ b/src/modules/user/controller/userControllers.ts @@ -267,7 +267,34 @@ const changeUserAddress = async (req: any, res: Response) => { } }; +const updatePasswordExpirationSetting = async (req: Request, res: Response) => { + try { + const { minutes } = req.body; + let setting = await userRepositories.findSettingByKey("PASSWORD_EXPIRATION_MINUTES"); + if (!setting) { + setting = await userRepositories.createSetting("PASSWORD_EXPIRATION_MINUTES", minutes); + } else { + setting = await userRepositories.updateSettingValue(setting, minutes); + } + res.status(httpStatus.OK).json({ message: "Password expiration setting updated successfully." }); + } catch (error) { + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ message: error.message }); + } +}; + +const getPasswordExpiration = async (req: Request, res: Response) => { + try { + const setting = await userRepositories.findSettingByKey("PASSWORD_EXPIRATION_MINUTES"); + if (setting) { + res.status(200).json({ minutes: setting.value }); + } else { + res.status(404).json({ message: "Password expiration setting not found." }); + } + } catch (error) { + res.status(500).json({ message: "Failed to fetch password expiration time." }); + } +}; export default { updateUserStatus, @@ -282,5 +309,7 @@ export default { markNotificationAsRead, markAllNotificationsAsRead, submitSellerRequest, - changeUserAddress + changeUserAddress, + updatePasswordExpirationSetting, + getPasswordExpiration }; \ No newline at end of file diff --git a/src/modules/user/repository/userRepositories.ts b/src/modules/user/repository/userRepositories.ts index 4f76d883..4989d3f2 100644 --- a/src/modules/user/repository/userRepositories.ts +++ b/src/modules/user/repository/userRepositories.ts @@ -93,6 +93,19 @@ const getAllShops = async () => { return await db.Shops.findAll(); }; +const findSettingByKey = async (key: string) => { + return await db.Settings.findOne({ where: { key } }); +}; + +const createSetting = async (key: string, value: string) => { + return await db.Settings.create({ key, value }); +}; + +const updateSettingValue = async (setting: any, value: string) => { + setting.value = value; + return await setting.save(); +}; + export default { getAllUsers, updateUserProfile, @@ -109,5 +122,8 @@ export default { updateUserAddress, addUserAddress, findAddressByUserId, - getAllShops + getAllShops, + findSettingByKey, + createSetting, + updateSettingValue }; \ No newline at end of file diff --git a/src/modules/user/validation/userValidations.ts b/src/modules/user/validation/userValidations.ts index 35b3d18d..14bfdc47 100644 --- a/src/modules/user/validation/userValidations.ts +++ b/src/modules/user/validation/userValidations.ts @@ -98,4 +98,13 @@ export const changeAddressSchema = Joi.object({ district: Joi.string().required(), sector: Joi.string().required(), street: Joi.string().required() +}); + +export const passwordExpirationTimeSchema = Joi.object({ + minutes: Joi.number().integer().min(1).required().messages({ + "number.base": "Minutes should be a number.", + "number.integer": "Minutes should be an integer.", + "number.min": "Minutes should be at least 1.", + "any.required": "Minutes is required." + }) }); \ No newline at end of file diff --git a/src/routes/userRouter.ts b/src/routes/userRouter.ts index 6a1e4a9e..07b46048 100644 --- a/src/routes/userRouter.ts +++ b/src/routes/userRouter.ts @@ -2,7 +2,7 @@ import { Router } from "express"; import userControllers from "../modules/user/controller/userControllers"; import { isUserExist, validation, isUsersExist, credential, isNotificationsExist, isUserProfileComplete, isSellerRequestExist } from "../middlewares/validation"; import { userAuthorization } from "../middlewares/authorization"; -import { statusSchema, roleSchema, userSchema, changePasswordSchema, changeAddressSchema } from "../modules/user/validation/userValidations"; +import { statusSchema, roleSchema, userSchema, changePasswordSchema, changeAddressSchema, passwordExpirationTimeSchema } from "../modules/user/validation/userValidations"; import upload from "../helpers/multer"; const router = Router(); @@ -11,12 +11,14 @@ import upload from "../helpers/multer"; router.get("/admin-get-user/:id", userAuthorization(["admin"]), isUserExist, userControllers.adminGetUser); router.put("/admin-update-user-status/:id", userAuthorization(["admin"]), validation(statusSchema), isUserExist, userControllers.updateUserStatus); router.put("/admin-update-user-role/:id", userAuthorization(["admin"]), validation(roleSchema), isUserExist, userControllers.updateUserRole); - + router.put("/admin-update-password-expiration", userAuthorization(["admin"]), validation(passwordExpirationTimeSchema), userControllers.updatePasswordExpirationSetting); + router.get("/admin-get-password-expiration", userAuthorization(["admin"]), userControllers.getPasswordExpiration); router.get("/user-get-profile", userAuthorization(["admin", "buyer", "seller"]), userControllers.getUserDetails); router.put("/user-update-profile", userAuthorization(["admin", "buyer", "seller"]), upload.single("profilePicture"), validation(userSchema), userControllers.updateUserProfile); router.put("/change-password", userAuthorization(["admin", "buyer", "seller"]), validation(changePasswordSchema), credential, userControllers.changePassword); + router.get("/user-get-notifications", userAuthorization(["admin", "buyer", "seller"]), isNotificationsExist, userControllers.getAllNotifications); router.get("/user-get-notification/:id", userAuthorization(["admin", "buyer", "seller"]),isNotificationsExist, userControllers.getSingleNotification);