diff --git a/server/controllers/authController.js b/server/controllers/authController.js index 208b981fc..12d2f77f4 100755 --- a/server/controllers/authController.js +++ b/server/controllers/authController.js @@ -3,117 +3,98 @@ import { loginValidation, editUserBodyValidation, recoveryValidation, - recoveryTokenValidation, + recoveryTokenBodyValidation, newPasswordValidation, getUserByIdParamValidation, editUserByIdParamValidation, editUserByIdBodyValidation, editSuperadminUserByIdBodyValidation, } from "../validation/joi.js"; -import jwt from "jsonwebtoken"; -import { getTokenFromHeaders } from "../utils/utils.js"; -import crypto from "crypto"; -import { asyncHandler, createAuthError, createError } from "../utils/errorUtils.js"; +import { asyncHandler, createError } from "../utils/errorUtils.js"; const SERVICE_NAME = "authController"; +/** + * Authentication Controller + * + * Handles all authentication-related HTTP requests including user registration, + * login, password recovery, and user management operations. + * + * @class AuthController + * @description Manages user authentication and authorization operations + */ class AuthController { - constructor({ db, settingsService, emailService, jobQueue, stringService, logger }) { + /** + * Creates an instance of AuthController. + * + * @param {Object} dependencies - The dependencies required by the controller + * @param {Object} dependencies.db - Database service for data operations + * @param {Object} dependencies.settingsService - Service for application settings + * @param {Object} dependencies.emailService - Service for email operations + * @param {Object} dependencies.jobQueue - Service for job queue operations + * @param {Object} dependencies.stringService - Service for string/localization + * @param {Object} dependencies.logger - Logger service + * @param {Object} dependencies.userService - User business logic service + */ + constructor({ db, settingsService, emailService, jobQueue, stringService, logger, userService }) { this.db = db; this.settingsService = settingsService; this.emailService = emailService; this.jobQueue = jobQueue; this.stringService = stringService; this.logger = logger; + this.userService = userService; } /** - * Creates and returns JWT token with an arbitrary payload - * @function - * @param {Object} payload - * @param {Object} appSettings - * @returns {String} - * @throws {Error} - */ - issueToken = (payload, appSettings) => { - const tokenTTL = appSettings?.jwtTTL ?? "2h"; - const tokenSecret = appSettings?.jwtSecret; - const payloadData = payload; - return jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL }); - }; - - /** - * Registers a new user. If the user is the first account, a JWT secret is created. If not, an invite token is required. + * Registers a new user in the system. + * * @async - * @param {Object} req - The Express request object. - * @property {Object} req.body - The body of the request. - * @property {string} req.body.inviteToken - The invite token for registration. - * @property {Object} req.file - The file object for the user's profile image. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status, a message indicating the creation of the user, the created user data, and a JWT token. - * @throws {Error} If there is an error during the process, especially if there is a validation error (422). + * @function registerUser + * @param {Object} req - Express request object + * @param {Object} req.body - Request body containing user registration data + * @param {string} req.body.firstName - User's first name + * @param {string} req.body.lastName - User's last name + * @param {string} req.body.email - User's email address (will be converted to lowercase) + * @param {string} req.body.password - User's password + * @param {string} [req.body.inviteToken] - Invite token for registration (required if superadmin exists) + * @param {string} [req.body.teamId] - Team ID (auto-assigned if superadmin) + * @param {Array} [req.body.role] - User roles (auto-assigned if superadmin) + * @param {Object} [req.file] - Profile image file uploaded via multer + * @param {Object} res - Express response object + * @returns {Promise} Success response with user data and JWT token + * @throws {Error} 422 - Validation error if request body is invalid + * @throws {Error} 409 - Conflict if user already exists + * @example + * // Register first user (becomes superadmin) + * POST /auth/register + * { + * "firstName": "John", + * "lastName": "Doe", + * "email": "john@example.com", + * "password": "SecurePass123!" + * } + * + * // Register subsequent user (requires invite token) + * POST /auth/register + * { + * "firstName": "Jane", + * "lastName": "Smith", + * "email": "jane@example.com", + * "password": "SecurePass123!", + * "inviteToken": "abc123..." + * } */ registerUser = asyncHandler( - async (req, res, next) => { + async (req, res) => { if (req.body?.email) { req.body.email = req.body.email?.toLowerCase(); } await registrationBodyValidation.validateAsync(req.body); - - // Create a new user - const user = req.body; - // If superAdmin exists, a token should be attached to all further register requests - const superAdminExists = await this.db.checkSuperadmin(req, res); - if (superAdminExists) { - const invitedUser = await this.db.getInviteTokenAndDelete(user.inviteToken); - user.role = invitedUser.role; - user.teamId = invitedUser.teamId; - } else { - // This is the first account, create JWT secret to use if one is not supplied by env - const jwtSecret = crypto.randomBytes(64).toString("hex"); - await this.db.updateAppSettings({ jwtSecret }); - } - - const newUser = await this.db.insertUser({ ...req.body }, req.file); - this.logger.info({ - message: this.stringService.authCreateUser, - service: SERVICE_NAME, - details: newUser._id, - }); - - const userForToken = { ...newUser._doc }; - delete userForToken.profileImage; - delete userForToken.avatarImage; - - const appSettings = await this.settingsService.getSettings(); - - const token = this.issueToken(userForToken, appSettings); - - try { - const html = await this.emailService.buildEmail("welcomeEmailTemplate", { - name: newUser.firstName, - }); - this.emailService.sendEmail(newUser.email, "Welcome to Uptime Monitor", html).catch((error) => { - this.logger.warn({ - message: error.message, - service: SERVICE_NAME, - method: "registerUser", - stack: error.stack, - }); - }); - } catch (error) { - this.logger.warn({ - message: error.message, - service: SERVICE_NAME, - method: "registerUser", - stack: error.stack, - }); - } - + const { user, token } = await this.userService.registerUser(req.body, req.file); res.success({ msg: this.stringService.authCreateUser, - data: { user: newUser, token: token }, + data: { user, token }, }); }, SERVICE_NAME, @@ -121,51 +102,38 @@ class AuthController { ); /** - * Logs in a user by validating the user's credentials and issuing a JWT token. + * Authenticates a user and returns a JWT token. + * * @async - * @param {Object} req - The Express request object. - * @property {Object} req.body - The body of the request. - * @property {string} req.body.email - The email of the user. - * @property {string} req.body.password - The password of the user. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status, a message indicating the login of the user, the user data (without password and avatar image), and a JWT token. - * @throws {Error} If there is an error during the process, especially if there is a validation error (422) or the password is incorrect. + * @function loginUser + * @param {Object} req - Express request object + * @param {Object} req.body - Request body containing login credentials + * @param {string} req.body.email - User's email address (will be converted to lowercase) + * @param {string} req.body.password - User's password + * @param {Object} res - Express response object + * @returns {Promise} Success response with user data and JWT token + * @throws {Error} 422 - Validation error if request body is invalid + * @throws {Error} 401 - Unauthorized if credentials are incorrect + * @example + * POST /auth/login + * { + * "email": "john@example.com", + * "password": "SecurePass123!" + * } */ loginUser = asyncHandler( - async (req, res, next) => { + async (req, res) => { if (req.body?.email) { req.body.email = req.body.email?.toLowerCase(); } await loginValidation.validateAsync(req.body); - - const { email, password } = req.body; - - // Check if user exists - const user = await this.db.getUserByEmail(email); - - // Compare password - const match = await user.comparePassword(password); - if (match !== true) { - throw createAuthError(this.stringService.authIncorrectPassword); - } - - // Remove password from user object. Should this be abstracted to DB layer? - const userWithoutPassword = { ...user._doc }; - delete userWithoutPassword.password; - delete userWithoutPassword.avatarImage; - - // Happy path, return token - const appSettings = await this.settingsService.getSettings(); - const token = this.issueToken(userWithoutPassword, appSettings); - // reset avatar image - userWithoutPassword.avatarImage = user.avatarImage; + const { user, token } = await this.userService.loginUser(req.body.email, req.body.password); return res.success({ msg: this.stringService.authLoginUser, data: { - user: userWithoutPassword, - token: token, + user, + token, }, }); }, @@ -174,46 +142,43 @@ class AuthController { ); /** - * Edits a user's information. If the user wants to change their password, the current password is checked before updating to the new password. + * Updates the current user's profile information. + * * @async - * @param {Object} req - The Express request object. - * @property {Object} req.params - The parameters of the request. - * @property {string} req.params.userId - The ID of the user to be edited. - * @property {Object} req.body - The body of the request. - * @property {string} req.body.password - The current password of the user. - * @property {string} req.body.newPassword - The new password of the user. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status, a message indicating the update of the user, and the updated user data. - * @throws {Error} If there is an error during the process, especially if there is a validation error (422), the user is unauthorized (401), or the password is incorrect (403). + * @function editUser + * @param {Object} req - Express request object + * @param {Object} req.body - Request body containing user update data + * @param {string} [req.body.firstName] - Updated first name + * @param {string} [req.body.lastName] - Updated last name + * @param {string} [req.body.password] - Current password (required for password change) + * @param {string} [req.body.newPassword] - New password (required for password change) + * @param {boolean} [req.body.deleteProfileImage] - Flag to delete profile image + * @param {Object} [req.file] - New profile image file + * @param {Object} req.user - Current authenticated user (from JWT) + * @param {Object} res - Express response object + * @returns {Promise} Success response with updated user data + * @throws {Error} 422 - Validation error if request body is invalid + * @throws {Error} 403 - Forbidden if current password is incorrect + * @example + * PUT /auth/user + * { + * "firstName": "John Updated", + * "lastName": "Doe Updated" + * } + * + * // Change password + * PUT /auth/user + * { + * "password": "OldPass123!", + * "newPassword": "NewPass123!" + * } */ editUser = asyncHandler( - async (req, res, next) => { + async (req, res) => { await editUserBodyValidation.validateAsync(req.body); - // Change Password check - if (req.body.password && req.body.newPassword) { - // Get token from headers - const token = getTokenFromHeaders(req.headers); - // Get email from token - const { jwtSecret } = this.settingsService.getSettings(); - const { email } = jwt.verify(token, jwtSecret); - // Add user email to body for DB operation - req.body.email = email; - // Get user - const user = await this.db.getUserByEmail(email); - // Compare passwords - const match = await user.comparePassword(req.body.password); - // If not a match, throw a 403 - // 403 instead of 401 to avoid triggering axios interceptor - if (!match) { - throw createError(this.stringService.authIncorrectPassword, 403); - } - // If a match, update the password - req.body.password = req.body.newPassword; - } + const updatedUser = await this.userService.editUser(req.body, req.file, req.user); - const updatedUser = await this.db.updateUser({ userId: req?.user?._id, user: req.body, file: req.file }); res.success({ msg: this.stringService.authUpdateUser, data: updatedUser, @@ -224,17 +189,20 @@ class AuthController { ); /** - * Checks if a superadmin account exists in the database. + * Checks if a superadmin account exists in the system. + * * @async - * @param {Object} req - The Express request object. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status, a message indicating the existence of a superadmin, and a boolean indicating the existence of a superadmin. - * @throws {Error} If there is an error during the process. + * @function checkSuperadminExists + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @returns {Promise} Success response with boolean indicating superadmin existence + * @example + * GET /auth/users/superadmin + * // Response: { "data": true } or { "data": false } */ checkSuperadminExists = asyncHandler( - async (req, res, next) => { - const superAdminExists = await this.db.checkSuperadmin(req, res); + async (req, res) => { + const superAdminExists = await this.userService.checkSuperadminExists(); return res.success({ msg: this.stringService.authAdminExists, data: superAdminExists, @@ -243,34 +211,30 @@ class AuthController { SERVICE_NAME, "checkSuperadminExists" ); + /** - * Requests a recovery token for a user. The user's email is validated and a recovery token is created and sent via email. + * Initiates password recovery process by sending a recovery email. + * * @async - * @param {Object} req - The Express request object. - * @property {Object} req.body - The body of the request. - * @property {string} req.body.email - The email of the user requesting recovery. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status, a message indicating the creation of the recovery token, and the message ID of the sent email. - * @throws {Error} If there is an error during the process, especially if there is a validation error (422). + * @function requestRecovery + * @param {Object} req - Express request object + * @param {Object} req.body - Request body containing email + * @param {string} req.body.email - Email address for password recovery + * @param {Object} res - Express response object + * @returns {Promise} Success response with message ID + * @throws {Error} 422 - Validation error if email is invalid + * @throws {Error} 404 - Not found if user doesn't exist + * @example + * POST /auth/recovery/request + * { + * "email": "john@example.com" + * } */ requestRecovery = asyncHandler( - async (req, res, next) => { + async (req, res) => { await recoveryValidation.validateAsync(req.body); - const { email } = req.body; - const user = await this.db.getUserByEmail(email); - const recoveryToken = await this.db.requestRecoveryToken(req, res); - const name = user.firstName; - const { clientHost } = this.settingsService.getSettings(); - const url = `${clientHost}/set-new-password/${recoveryToken.token}`; - - const html = await this.emailService.buildEmail("passwordResetTemplate", { - name, - email, - url, - }); - const msgId = await this.emailService.sendEmail(email, "Checkmate Password Reset", html); - + const email = req?.body?.email; + const msgId = await this.userService.requestRecovery(email); return res.success({ msg: this.stringService.authCreateRecoveryToken, data: msgId, @@ -279,21 +243,29 @@ class AuthController { SERVICE_NAME, "requestRecovery" ); + /** - * Validates a recovery token. The recovery token is validated and if valid, a success message is returned. + * Validates a password recovery token. + * * @async - * @param {Object} req - The Express request object. - * @property {Object} req.body - The body of the request. - * @property {string} req.body.token - The recovery token to be validated. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status and a message indicating the validation of the recovery token. - * @throws {Error} If there is an error during the process, especially if there is a validation error (422). + * @function validateRecovery + * @param {Object} req - Express request object + * @param {Object} req.body - Request body containing recovery token + * @param {string} req.body.recoveryToken - Recovery token to validate + * @param {Object} res - Express response object + * @returns {Promise} Success response if token is valid + * @throws {Error} 422 - Validation error if token format is invalid + * @throws {Error} 400 - Bad request if token is invalid or expired + * @example + * POST /auth/recovery/validate + * { + * "recoveryToken": "abc123..." + * } */ validateRecovery = asyncHandler( - async (req, res, next) => { - await recoveryTokenValidation.validateAsync(req.body); - await this.db.validateRecoveryToken(req, res); + async (req, res) => { + await recoveryTokenBodyValidation.validateAsync(req.body); + await this.userService.validateRecovery(req.body.recoveryToken); return res.success({ msg: this.stringService.authVerifyRecoveryToken, }); @@ -303,23 +275,29 @@ class AuthController { ); /** - * Resets a user's password. The new password is validated and if valid, the user's password is updated in the database and a new JWT token is issued. + * Resets user password using a valid recovery token. + * * @async - * @param {Object} req - The Express request object. - * @property {Object} req.body - The body of the request. - * @property {string} req.body.token - The recovery token. - * @property {string} req.body.password - The new password of the user. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status, a message indicating the reset of the password, the updated user data (without password and avatar image), and a new JWT token. - * @throws {Error} If there is an error during the process, especially if there is a validation error (422). + * @function resetPassword + * @param {Object} req - Express request object + * @param {Object} req.body - Request body containing new password and recovery token + * @param {string} req.body.password - New password + * @param {string} req.body.recoveryToken - Valid recovery token + * @param {Object} res - Express response object + * @returns {Promise} Success response with user data and JWT token + * @throws {Error} 422 - Validation error if password format is invalid + * @throws {Error} 400 - Bad request if token is invalid or expired + * @example + * POST /auth/recovery/reset + * { + * "password": "NewSecurePass123!", + * "recoveryToken": "abc123..." + * } */ resetPassword = asyncHandler( - async (req, res, next) => { + async (req, res) => { await newPasswordValidation.validateAsync(req.body); - const user = await this.db.resetPassword(req, res); - const appSettings = await this.settingsService.getSettings(); - const token = this.issueToken(user._doc, appSettings); + const { user, token } = await this.userService.resetPassword(req.body.password, req.body.recoveryToken); return res.success({ msg: this.stringService.authResetPassword, data: { user, token }, @@ -330,54 +308,27 @@ class AuthController { ); /** - * Deletes a user and all associated monitors, checks, and alerts. + * Deletes the current user's account and associated data. * - * @param {Object} req - The request object. - * @param {Object} res - The response object. - * @param {Function} next - The next middleware function. - * @returns {Object} The response object with success status and message. - * @throws {Error} If user validation fails or user is not found in the database. + * @async + * @function deleteUser + * @param {Object} req - Express request object + * @param {Object} req.user - Current authenticated user (from JWT) + * @param {string} req.user._id - User ID + * @param {string} req.user.email - User email + * @param {string} req.user.teamId - User's team ID + * @param {Array} req.user.role - User roles + * @param {Object} res - Express response object + * @returns {Promise} Success response confirming user deletion + * @throws {Error} 400 - Bad request if user is demo user + * @throws {Error} 404 - Not found if user doesn't exist + * @example + * DELETE /auth/user + * // Requires JWT authentication */ deleteUser = asyncHandler( - async (req, res, next) => { - const email = req?.user?.email; - if (!email) { - throw new Error("No email in request"); - } - - const teamId = req?.user?.teamId; - const userId = req?.user?._id; - - if (!teamId) { - throw new Error("No team ID in request"); - } - - if (!userId) { - throw new Error("No user ID in request"); - } - - const roles = req.user.role; - if (roles.includes("demo")) { - throw new Error("Demo user cannot be deleted"); - } - - // 1. Find all the monitors associated with the team ID if superadmin - const result = await this.db.getMonitorsByTeamId({ - teamId: teamId, - }); - - if (roles.includes("superadmin")) { - // 2. Remove all jobs, delete checks and alerts - result?.monitors.length > 0 && - (await Promise.all( - result.monitors.map(async (monitor) => { - await this.jobQueue.deleteJob(monitor); - }) - )); - } - // 6. Delete the user by id - await this.db.deleteUser(userId); - + async (req, res) => { + await this.userService.deleteUser(req.user); return res.success({ msg: this.stringService.authDeleteUser, }); @@ -386,9 +337,22 @@ class AuthController { "deleteUser" ); + /** + * Retrieves all users in the system (admin/superadmin only). + * + * @async + * @function getAllUsers + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @returns {Promise} Success response with array of users + * @throws {Error} 403 - Forbidden if user doesn't have admin/superadmin role + * @example + * GET /auth/users + * // Requires JWT authentication with admin/superadmin role + */ getAllUsers = asyncHandler( - async (req, res, next) => { - const allUsers = await this.db.getAllUsers(req, res); + async (req, res) => { + const allUsers = await this.userService.getAllUsers(); return res.success({ msg: this.stringService.authGetAllUsers, data: allUsers, @@ -398,8 +362,27 @@ class AuthController { "getAllUsers" ); + /** + * Retrieves a specific user by ID (superadmin only). + * + * @async + * @function getUserById + * @param {Object} req - Express request object + * @param {Object} req.params - URL parameters + * @param {string} req.params.userId - ID of the user to retrieve + * @param {Object} req.user - Current authenticated user (from JWT) + * @param {Array} req.user.role - Current user's roles + * @param {Object} res - Express response object + * @returns {Promise} Success response with user data + * @throws {Error} 422 - Validation error if userId is invalid + * @throws {Error} 403 - Forbidden if user doesn't have superadmin role + * @throws {Error} 404 - Not found if user doesn't exist + * @example + * GET /auth/users/507f1f77bcf86cd799439011 + * // Requires JWT authentication with superadmin role + */ getUserById = asyncHandler( - async (req, res, next) => { + async (req, res) => { await getUserByIdParamValidation.validateAsync(req.params); const userId = req?.params?.userId; const roles = req?.user?.role; @@ -412,7 +395,7 @@ class AuthController { throw new Error("No roles in request"); } - const user = await this.db.getUserById(roles, userId); + const user = await this.userService.getUserById(roles, userId); return res.success({ msg: "ok", data: user }); }, @@ -420,8 +403,36 @@ class AuthController { "getUserById" ); + /** + * Updates a specific user by ID (superadmin only). + * + * @async + * @function editUserById + * @param {Object} req - Express request object + * @param {Object} req.params - URL parameters + * @param {string} req.params.userId - ID of the user to update + * @param {Object} req.body - Request body containing user update data + * @param {string} [req.body.firstName] - Updated first name + * @param {string} [req.body.lastName] - Updated last name + * @param {Array} [req.body.role] - Updated user roles + * @param {Object} req.user - Current authenticated user (from JWT) + * @param {string} req.user._id - Current user's ID + * @param {Array} req.user.role - Current user's roles + * @param {Object} res - Express response object + * @returns {Promise} Success response confirming user update + * @throws {Error} 422 - Validation error if parameters or body are invalid + * @throws {Error} 403 - Forbidden if user doesn't have superadmin role + * @throws {Error} 404 - Not found if user doesn't exist + * @example + * PUT /auth/users/507f1f77bcf86cd799439011 + * { + * "firstName": "Updated Name", + * "role": ["admin"] + * } + * // Requires JWT authentication with superadmin role + */ editUserById = asyncHandler( - async (req, res, next) => { + async (req, res) => { const roles = req?.user?.role; if (!roles.includes("superadmin")) { throw createError("Unauthorized", 403); @@ -438,7 +449,7 @@ class AuthController { await editUserByIdBodyValidation.validateAsync(req.body); } - await this.db.editUserById(userId, user); + await this.userService.editUserById(userId, user); return res.success({ msg: "ok" }); }, SERVICE_NAME, diff --git a/server/db/mongo/MongoDB.js b/server/db/mongo/MongoDB.js index 6f6a13d64..6eb82384f 100755 --- a/server/db/mongo/MongoDB.js +++ b/server/db/mongo/MongoDB.js @@ -138,13 +138,6 @@ class MongoDB { }); } }; - checkSuperadmin = async (req, res) => { - const superAdmin = await UserModel.findOne({ role: "superadmin" }); - if (superAdmin !== null) { - return true; - } - return false; - }; } export default MongoDB; diff --git a/server/db/mongo/modules/inviteModule.js b/server/db/mongo/modules/inviteModule.js index f5c960697..89d8ad8d7 100755 --- a/server/db/mongo/modules/inviteModule.js +++ b/server/db/mongo/modules/inviteModule.js @@ -1,7 +1,7 @@ import InviteToken from "../../models/InviteToken.js"; import crypto from "crypto"; -import ServiceRegistry from "../../../service/serviceRegistry.js"; -import StringService from "../../../service/stringService.js"; +import ServiceRegistry from "../../../service/system/serviceRegistry.js"; +import StringService from "../../../service/system/stringService.js"; const SERVICE_NAME = "inviteModule"; /** diff --git a/server/db/mongo/modules/monitorModule.js b/server/db/mongo/modules/monitorModule.js index 3192b711c..18cb3c63f 100755 --- a/server/db/mongo/modules/monitorModule.js +++ b/server/db/mongo/modules/monitorModule.js @@ -4,8 +4,8 @@ import Check from "../../models/Check.js"; import PageSpeedCheck from "../../models/PageSpeedCheck.js"; import HardwareCheck from "../../models/HardwareCheck.js"; import { NormalizeData, NormalizeDataUptimeDetails } from "../../../utils/dataUtils.js"; -import ServiceRegistry from "../../../service/serviceRegistry.js"; -import StringService from "../../../service/stringService.js"; +import ServiceRegistry from "../../../service/system/serviceRegistry.js"; +import StringService from "../../../service/system/stringService.js"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; diff --git a/server/db/mongo/modules/recoveryModule.js b/server/db/mongo/modules/recoveryModule.js index 3b39e847c..2678f49c4 100755 --- a/server/db/mongo/modules/recoveryModule.js +++ b/server/db/mongo/modules/recoveryModule.js @@ -1,25 +1,24 @@ import UserModel from "../../models/User.js"; import RecoveryToken from "../../models/RecoveryToken.js"; import crypto from "crypto"; -import serviceRegistry from "../../../service/serviceRegistry.js"; -import StringService from "../../../service/stringService.js"; +import serviceRegistry from "../../../service/system/serviceRegistry.js"; +import StringService from "../../../service/system/stringService.js"; const SERVICE_NAME = "recoveryModule"; /** * Request a recovery token * @async - * @param {Express.Request} req - * @param {Express.Response} res + * @param {string} email * @returns {Promise} * @throws {Error} */ -const requestRecoveryToken = async (req, res) => { +const requestRecoveryToken = async (email) => { try { // Delete any existing tokens - await RecoveryToken.deleteMany({ email: req.body.email }); + await RecoveryToken.deleteMany({ email }); let recoveryToken = new RecoveryToken({ - email: req.body.email, + email, token: crypto.randomBytes(32).toString("hex"), }); await recoveryToken.save(); @@ -31,10 +30,9 @@ const requestRecoveryToken = async (req, res) => { } }; -const validateRecoveryToken = async (req, res) => { +const validateRecoveryToken = async (candidateToken) => { const stringService = serviceRegistry.get(StringService.SERVICE_NAME); try { - const candidateToken = req.body.recoveryToken; const recoveryToken = await RecoveryToken.findOne({ token: candidateToken, }); @@ -50,13 +48,13 @@ const validateRecoveryToken = async (req, res) => { } }; -const resetPassword = async (req, res) => { +const resetPassword = async (password, candidateToken) => { const stringService = serviceRegistry.get(StringService.SERVICE_NAME); try { - const newPassword = req.body.password; + const newPassword = password; // Validate token again - const recoveryToken = await validateRecoveryToken(req, res); + const recoveryToken = await validateRecoveryToken(candidateToken); const user = await UserModel.findOne({ email: recoveryToken.email }); if (user === null) { diff --git a/server/db/mongo/modules/statusPageModule.js b/server/db/mongo/modules/statusPageModule.js index c126d913f..757fdd266 100755 --- a/server/db/mongo/modules/statusPageModule.js +++ b/server/db/mongo/modules/statusPageModule.js @@ -1,7 +1,7 @@ import StatusPage from "../../models/StatusPage.js"; import { NormalizeData } from "../../../utils/dataUtils.js"; -import ServiceRegistry from "../../../service/serviceRegistry.js"; -import StringService from "../../../service/stringService.js"; +import ServiceRegistry from "../../../service/system/serviceRegistry.js"; +import StringService from "../../../service/system/stringService.js"; const SERVICE_NAME = "statusPageModule"; diff --git a/server/db/mongo/modules/userModule.js b/server/db/mongo/modules/userModule.js index 311a345e1..974f2d2ed 100755 --- a/server/db/mongo/modules/userModule.js +++ b/server/db/mongo/modules/userModule.js @@ -4,10 +4,18 @@ import { GenerateAvatarImage } from "../../../utils/imageProcessing.js"; const DUPLICATE_KEY_CODE = 11000; // MongoDB error code for duplicate key import { ParseBoolean } from "../../../utils/utils.js"; -import ServiceRegistry from "../../../service/serviceRegistry.js"; -import StringService from "../../../service/stringService.js"; +import ServiceRegistry from "../../../service/system/serviceRegistry.js"; +import StringService from "../../../service/system/stringService.js"; const SERVICE_NAME = "userModule"; +const checkSuperadmin = async () => { + const superAdmin = await UserModel.findOne({ role: "superadmin" }); + if (superAdmin !== null) { + return true; + } + return false; +}; + /** * Insert a User * @async @@ -187,7 +195,7 @@ const deleteAllOtherUsers = async () => { } }; -const getAllUsers = async (req, res) => { +const getAllUsers = async () => { try { const users = await UserModel.find().select("-password").select("-profileImage"); return users; @@ -238,4 +246,16 @@ const editUserById = async (userId, user) => { } }; -export { insertUser, getUserByEmail, updateUser, deleteUser, deleteTeam, deleteAllOtherUsers, getAllUsers, logoutUser, getUserById, editUserById }; +export { + checkSuperadmin, + insertUser, + getUserByEmail, + updateUser, + deleteUser, + deleteTeam, + deleteAllOtherUsers, + getAllUsers, + logoutUser, + getUserById, + editUserById, +}; diff --git a/server/index.js b/server/index.js index 8fb8942de..996eae4d1 100755 --- a/server/index.js +++ b/server/index.js @@ -1,6 +1,7 @@ import path from "path"; import fs from "fs"; import swaggerUi from "swagger-ui-express"; +import jwt from "jsonwebtoken"; import express from "express"; import helmet from "helmet"; @@ -46,56 +47,58 @@ import DiagnosticRoutes from "./routes/diagnosticRoute.js"; import DiagnosticController from "./controllers/diagnosticController.js"; //JobQueue service and dependencies -import JobQueue from "./service/JobQueue/JobQueue.js"; -import JobQueueHelper from "./service/JobQueue/JobQueueHelper.js"; +import JobQueue from "./service/infrastructure/JobQueue/JobQueue.js"; +import JobQueueHelper from "./service/infrastructure/JobQueue/JobQueueHelper.js"; import { Queue, Worker } from "bullmq"; -import PulseQueue from "./service/PulseQueue/PulseQueue.js"; -import PulseQueueHelper from "./service/PulseQueue/PulseQueueHelper.js"; +import PulseQueue from "./service/infrastructure/PulseQueue/PulseQueue.js"; +import PulseQueueHelper from "./service/infrastructure/PulseQueue/PulseQueueHelper.js"; -import SuperSimpleQueue from "./service/SuperSimpleQueue/SuperSimpleQueue.js"; -import SuperSimpleQueueHelper from "./service/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +import SuperSimpleQueue from "./service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js"; +import SuperSimpleQueueHelper from "./service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; + +import UserService from "./service/business/userService.js"; //Network service and dependencies -import NetworkService from "./service/networkService.js"; +import NetworkService from "./service/infrastructure/networkService.js"; import axios from "axios"; import ping from "ping"; import http from "http"; import Docker from "dockerode"; import net from "net"; // Email service and dependencies -import EmailService from "./service/emailService.js"; +import EmailService from "./service/infrastructure/emailService.js"; import nodemailer from "nodemailer"; import pkg from "handlebars"; const { compile } = pkg; import mjml2html from "mjml"; // Settings Service and dependencies -import SettingsService from "./service/settingsService.js"; +import SettingsService from "./service/system/settingsService.js"; import AppSettings from "./db/models/AppSettings.js"; // Status Service and dependencies -import StatusService from "./service/statusService.js"; +import StatusService from "./service/infrastructure/statusService.js"; // Notification Service and dependencies -import NotificationService from "./service/notificationService.js"; -import NotificationUtils from "./service/notificationUtils.js"; +import NotificationService from "./service/infrastructure/notificationService.js"; +import NotificationUtils from "./service/infrastructure/notificationUtils.js"; // Buffer Service and dependencies -import BufferService from "./service/bufferService.js"; +import BufferService from "./service/infrastructure/bufferService.js"; // Service Registry -import ServiceRegistry from "./service/serviceRegistry.js"; +import ServiceRegistry from "./service/system/serviceRegistry.js"; import MongoDB from "./db/mongo/MongoDB.js"; // Redis Service and dependencies import IORedis from "ioredis"; -import RedisService from "./service/redisService.js"; +import RedisService from "./service/data/redisService.js"; -import TranslationService from "./service/translationService.js"; +import TranslationService from "./service/system/translationService.js"; import languageMiddleware from "./middleware/languageMiddleware.js"; -import StringService from "./service/stringService.js"; +import StringService from "./service/system/stringService.js"; const SERVICE_NAME = "Server"; const SHUTDOWN_TIMEOUT = 1000; @@ -176,6 +179,14 @@ const startApp = async () => { }); const redisService = new RedisService({ Redis: IORedis, logger }); + const userService = new UserService({ + db, + emailService, + settingsService, + logger, + stringService, + jwt, + }); // const jobQueueHelper = new JobQueueHelper({ // redisService, @@ -235,6 +246,7 @@ const startApp = async () => { ServiceRegistry.register(NotificationService.SERVICE_NAME, notificationService); ServiceRegistry.register(TranslationService.SERVICE_NAME, translationService); ServiceRegistry.register(RedisService.SERVICE_NAME, redisService); + ServiceRegistry.register(UserService.SERVICE_NAME, userService); await translationService.initialize(); @@ -255,6 +267,7 @@ const startApp = async () => { jobQueue: ServiceRegistry.get(JobQueue.SERVICE_NAME), stringService: ServiceRegistry.get(StringService.SERVICE_NAME), logger: logger, + userService: ServiceRegistry.get(UserService.SERVICE_NAME), }); const monitorController = new MonitorController( diff --git a/server/middleware/handleErrors.js b/server/middleware/handleErrors.js index f04df1a08..1efce6f67 100755 --- a/server/middleware/handleErrors.js +++ b/server/middleware/handleErrors.js @@ -1,6 +1,6 @@ import logger from "../utils/logger.js"; -import ServiceRegistry from "../service/serviceRegistry.js"; -import StringService from "../service/stringService.js"; +import ServiceRegistry from "../service/system/serviceRegistry.js"; +import StringService from "../service/system/stringService.js"; const handleErrors = (error, req, res, next) => { console.log("ERROR", error); diff --git a/server/middleware/isAllowed.js b/server/middleware/isAllowed.js index 27fb875ed..5d61974be 100755 --- a/server/middleware/isAllowed.js +++ b/server/middleware/isAllowed.js @@ -1,9 +1,9 @@ import jwt from "jsonwebtoken"; const TOKEN_PREFIX = "Bearer "; const SERVICE_NAME = "allowedRoles"; -import ServiceRegistry from "../service/serviceRegistry.js"; -import StringService from "../service/stringService.js"; -import SettingsService from "../service/settingsService.js"; +import ServiceRegistry from "../service/system/serviceRegistry.js"; +import StringService from "../service/system/stringService.js"; +import SettingsService from "../service/system/settingsService.js"; const isAllowed = (allowedRoles) => { return (req, res, next) => { diff --git a/server/middleware/verifyJWT.js b/server/middleware/verifyJWT.js index 46611c0dd..d01db0a97 100755 --- a/server/middleware/verifyJWT.js +++ b/server/middleware/verifyJWT.js @@ -1,7 +1,7 @@ import jwt from "jsonwebtoken"; -import ServiceRegistry from "../service/serviceRegistry.js"; -import SettingsService from "../service/settingsService.js"; -import StringService from "../service/stringService.js"; +import ServiceRegistry from "../service/system/serviceRegistry.js"; +import SettingsService from "../service/system/settingsService.js"; +import StringService from "../service/system/stringService.js"; const SERVICE_NAME = "verifyJWT"; const TOKEN_PREFIX = "Bearer "; diff --git a/server/middleware/verifyOwnership.js b/server/middleware/verifyOwnership.js index 2040d54ee..890c40b24 100755 --- a/server/middleware/verifyOwnership.js +++ b/server/middleware/verifyOwnership.js @@ -1,6 +1,6 @@ import logger from "../utils/logger.js"; -import ServiceRegistry from "../service/serviceRegistry.js"; -import StringService from "../service/stringService.js"; +import ServiceRegistry from "../service/system/serviceRegistry.js"; +import StringService from "../service/system/stringService.js"; import { ObjectId } from "mongodb"; const SERVICE_NAME = "verifyOwnership"; diff --git a/server/package-lock.json b/server/package-lock.json index 14dc57616..d2ba13f13 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1079,6 +1079,17 @@ "dev": true, "license": "(Unlicense OR Apache-2.0)" }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2252,12 +2263,14 @@ } }, "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "mdn-data": "2.12.2", + "mdn-data": "2.0.30", "source-map-js": "^1.0.1" }, "engines": { @@ -4547,10 +4560,12 @@ } }, "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "license": "CC0-1.0" + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "license": "CC0-1.0", + "optional": true, + "peer": true }, "node_modules/media-typer": { "version": "0.3.0", @@ -6381,6 +6396,59 @@ "postcss": "^8.4.32" } }, + "node_modules/postcss-svgo/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/postcss-svgo/node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/postcss-svgo/node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "license": "CC0-1.0" + }, + "node_modules/postcss-svgo/node_modules/svgo": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz", + "integrity": "sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.4.1" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, "node_modules/postcss-unique-selectors": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-7.0.4.tgz", @@ -7421,24 +7489,26 @@ } }, "node_modules/svgo": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz", - "integrity": "sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", + "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "commander": "^11.1.0", + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", "css-select": "^5.1.0", - "css-tree": "^3.0.1", + "css-tree": "^2.3.1", "css-what": "^6.1.0", "csso": "^5.0.5", - "picocolors": "^1.1.1", - "sax": "^1.4.1" + "picocolors": "^1.0.0" }, "bin": { - "svgo": "bin/svgo.js" + "svgo": "bin/svgo" }, "engines": { - "node": ">=16" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", @@ -7446,12 +7516,14 @@ } }, "node_modules/svgo/node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "license": "MIT", + "optional": true, + "peer": true, "engines": { - "node": ">=16" + "node": ">= 10" } }, "node_modules/swagger-ui-dist": { diff --git a/server/service/business/userService.js b/server/service/business/userService.js new file mode 100644 index 000000000..90906552d --- /dev/null +++ b/server/service/business/userService.js @@ -0,0 +1,208 @@ +const SERVICE_NAME = "userService"; +import { createAuthError, createError } from "../../utils/errorUtils.js"; + +class UserService { + static SERVICE_NAME = SERVICE_NAME; + constructor({ db, emailService, settingsService, logger, stringService, jwt }) { + this.db = db; + this.emailService = emailService; + this.settingsService = settingsService; + this.logger = logger; + this.stringService = stringService; + this.jwt = jwt; + } + + issueToken = (payload, appSettings) => { + const tokenTTL = appSettings?.jwtTTL ?? "2h"; + const tokenSecret = appSettings?.jwtSecret; + const payloadData = payload; + return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL }); + }; + + registerUser = async (user, file) => { + // Create a new user + // If superAdmin exists, a token should be attached to all further register requests + const superAdminExists = await this.db.checkSuperadmin(); + if (superAdminExists) { + const invitedUser = await this.db.getInviteTokenAndDelete(user.inviteToken); + user.role = invitedUser.role; + user.teamId = invitedUser.teamId; + } else { + // This is the first account, create JWT secret to use if one is not supplied by env + const jwtSecret = crypto.randomBytes(64).toString("hex"); + await this.db.updateAppSettings({ jwtSecret }); + } + + const newUser = await this.db.insertUser({ ...user }, file); + + this.logger.debug({ + message: "New user created", + service: SERVICE_NAME, + method: "registerUser", + details: newUser._id, + }); + + const userForToken = { ...newUser._doc }; + delete userForToken.profileImage; + delete userForToken.avatarImage; + + const appSettings = await this.settingsService.getSettings(); + + const token = this.issueToken(userForToken, appSettings); + + try { + const html = await this.emailService.buildEmail("welcomeEmailTemplate", { + name: newUser.firstName, + }); + this.emailService.sendEmail(newUser.email, "Welcome to Uptime Monitor", html).catch((error) => { + this.logger.warn({ + message: error.message, + service: SERVICE_NAME, + method: "registerUser", + stack: error.stack, + }); + }); + } catch (error) { + this.logger.warn({ + message: error.message, + service: SERVICE_NAME, + method: "registerUser", + stack: error.stack, + }); + } + + return { user: newUser, token }; + }; + + loginUser = async (email, password) => { + // Check if user exists + const user = await this.db.getUserByEmail(email); + // Compare password + const match = await user.comparePassword(password); + if (match !== true) { + throw createAuthError(this.stringService.authIncorrectPassword); + } + + // Remove password from user object. Should this be abstracted to DB layer? + const userWithoutPassword = { ...user._doc }; + delete userWithoutPassword.password; + delete userWithoutPassword.avatarImage; + + // Happy path, return token + const appSettings = await this.settingsService.getSettings(); + const token = this.issueToken(userWithoutPassword, appSettings); + // reset avatar image + userWithoutPassword.avatarImage = user.avatarImage; + return { user: userWithoutPassword, token }; + }; + + editUser = async (updates, file, currentUser) => { + // Change Password check + if (updates?.password && updates?.newPassword) { + // Get user's email + // Add user email to body for DB operation + updates.email = currentUser.email; + // Get user + const user = await this.db.getUserByEmail(currentUser.email); + // Compare passwords + const match = await user.comparePassword(updates?.password); + // If not a match, throw a 403 + // 403 instead of 401 to avoid triggering axios interceptor + if (!match) { + throw createError(this.stringService.authIncorrectPassword, 403); + } + // If a match, update the password + updates.password = updates.newPassword; + } + + const updatedUser = await this.db.updateUser({ userId: currentUser?._id, user: updates, file: file }); + return updatedUser; + }; + + checkSuperadminExists = async () => { + const superAdminExists = await this.db.checkSuperadmin(); + return superAdminExists; + }; + + requestRecovery = async (email) => { + const user = await this.db.getUserByEmail(email); + const recoveryToken = await this.db.requestRecoveryToken(email); + const name = user.firstName; + const { clientHost } = this.settingsService.getSettings(); + const url = `${clientHost}/set-new-password/${recoveryToken.token}`; + + const html = await this.emailService.buildEmail("passwordResetTemplate", { + name, + email, + url, + }); + const msgId = await this.emailService.sendEmail(email, "Checkmate Password Reset", html); + return msgId; + }; + + validateRecovery = async (recoveryToken) => { + await this.db.validateRecoveryToken(recoveryToken); + }; + + resetPassword = async (password, recoveryToken) => { + const user = await this.db.resetPassword(password, recoveryToken); + const appSettings = await this.settingsService.getSettings(); + const token = this.issueToken(user._doc, appSettings); + return { user, token }; + }; + + deleteUser = async (user) => { + const email = user?.email; + if (!email) { + throw new Error("No email in request"); + } + + const teamId = user?.teamId; + const userId = user?._id; + + if (!teamId) { + throw new Error("No team ID in request"); + } + + if (!userId) { + throw new Error("No user ID in request"); + } + + const roles = user?.role; + if (roles.includes("demo")) { + throw new Error("Demo user cannot be deleted"); + } + + // 1. Find all the monitors associated with the team ID if superadmin + const result = await this.db.getMonitorsByTeamId({ + teamId: teamId, + }); + + if (roles.includes("superadmin")) { + // 2. Remove all jobs, delete checks and alerts + result?.monitors.length > 0 && + (await Promise.all( + result.monitors.map(async (monitor) => { + await this.jobQueue.deleteJob(monitor); + }) + )); + } + // 6. Delete the user by id + await this.db.deleteUser(userId); + }; + + getAllUsers = async () => { + const users = await this.db.getAllUsers(); + return users; + }; + + getUserById = async (roles, userId) => { + const user = await this.db.getUserById(roles, userId); + return user; + }; + + editUserById = async (userId, user) => { + await this.db.editUserById(userId, user); + }; +} +export default UserService; diff --git a/server/service/redisService.js b/server/service/data/redisService.js similarity index 100% rename from server/service/redisService.js rename to server/service/data/redisService.js diff --git a/server/service/JobQueue/JobQueue.js b/server/service/infrastructure/JobQueue/JobQueue.js similarity index 100% rename from server/service/JobQueue/JobQueue.js rename to server/service/infrastructure/JobQueue/JobQueue.js diff --git a/server/service/JobQueue/JobQueueHelper.js b/server/service/infrastructure/JobQueue/JobQueueHelper.js similarity index 100% rename from server/service/JobQueue/JobQueueHelper.js rename to server/service/infrastructure/JobQueue/JobQueueHelper.js diff --git a/server/service/PulseQueue/PulseQueue.js b/server/service/infrastructure/PulseQueue/PulseQueue.js similarity index 100% rename from server/service/PulseQueue/PulseQueue.js rename to server/service/infrastructure/PulseQueue/PulseQueue.js diff --git a/server/service/PulseQueue/PulseQueueHelper.js b/server/service/infrastructure/PulseQueue/PulseQueueHelper.js similarity index 100% rename from server/service/PulseQueue/PulseQueueHelper.js rename to server/service/infrastructure/PulseQueue/PulseQueueHelper.js diff --git a/server/service/SuperSimpleQueue/SuperSimpleQueue.js b/server/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js similarity index 100% rename from server/service/SuperSimpleQueue/SuperSimpleQueue.js rename to server/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js diff --git a/server/service/SuperSimpleQueue/SuperSimpleQueueHelper.js b/server/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js similarity index 100% rename from server/service/SuperSimpleQueue/SuperSimpleQueueHelper.js rename to server/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js diff --git a/server/service/bufferService.js b/server/service/infrastructure/bufferService.js similarity index 100% rename from server/service/bufferService.js rename to server/service/infrastructure/bufferService.js diff --git a/server/service/emailService.js b/server/service/infrastructure/emailService.js similarity index 100% rename from server/service/emailService.js rename to server/service/infrastructure/emailService.js diff --git a/server/service/networkService.js b/server/service/infrastructure/networkService.js similarity index 100% rename from server/service/networkService.js rename to server/service/infrastructure/networkService.js diff --git a/server/service/notificationService.js b/server/service/infrastructure/notificationService.js similarity index 100% rename from server/service/notificationService.js rename to server/service/infrastructure/notificationService.js diff --git a/server/service/notificationUtils.js b/server/service/infrastructure/notificationUtils.js similarity index 100% rename from server/service/notificationUtils.js rename to server/service/infrastructure/notificationUtils.js diff --git a/server/service/statusService.js b/server/service/infrastructure/statusService.js similarity index 98% rename from server/service/statusService.js rename to server/service/infrastructure/statusService.js index 1982bbb30..1c91a407c 100755 --- a/server/service/statusService.js +++ b/server/service/infrastructure/statusService.js @@ -1,5 +1,5 @@ -import MonitorStats from "../db/models/MonitorStats.js"; -import { safelyParseFloat } from "../utils/dataUtils.js"; +import MonitorStats from "../../db/models/MonitorStats.js"; +import { safelyParseFloat } from "../../utils/dataUtils.js"; const SERVICE_NAME = "StatusService"; class StatusService { diff --git a/server/service/serviceRegistry.js b/server/service/system/serviceRegistry.js similarity index 93% rename from server/service/serviceRegistry.js rename to server/service/system/serviceRegistry.js index 69a3b8be5..5f8b3bfe6 100755 --- a/server/service/serviceRegistry.js +++ b/server/service/system/serviceRegistry.js @@ -1,5 +1,5 @@ const SERVICE_NAME = "ServiceRegistry"; -import logger from "../utils/logger.js"; +import logger from "../../utils/logger.js"; class ServiceRegistry { static SERVICE_NAME = SERVICE_NAME; constructor() { diff --git a/server/service/settingsService.js b/server/service/system/settingsService.js similarity index 100% rename from server/service/settingsService.js rename to server/service/system/settingsService.js diff --git a/server/service/stringService.js b/server/service/system/stringService.js similarity index 100% rename from server/service/stringService.js rename to server/service/system/stringService.js diff --git a/server/service/translationService.js b/server/service/system/translationService.js similarity index 100% rename from server/service/translationService.js rename to server/service/system/translationService.js diff --git a/server/service/userService.js b/server/service/userService.js new file mode 100644 index 000000000..3e1bf285f --- /dev/null +++ b/server/service/userService.js @@ -0,0 +1,208 @@ +const SERVICE_NAME = "userService"; +import { createAuthError, createError } from "../utils/errorUtils.js"; + +class UserService { + static SERVICE_NAME = SERVICE_NAME; + constructor({ db, emailService, settingsService, logger, stringService, jwt }) { + this.db = db; + this.emailService = emailService; + this.settingsService = settingsService; + this.logger = logger; + this.stringService = stringService; + this.jwt = jwt; + } + + issueToken = (payload, appSettings) => { + const tokenTTL = appSettings?.jwtTTL ?? "2h"; + const tokenSecret = appSettings?.jwtSecret; + const payloadData = payload; + return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL }); + }; + + registerUser = async (user, file) => { + // Create a new user + // If superAdmin exists, a token should be attached to all further register requests + const superAdminExists = await this.db.checkSuperadmin(); + if (superAdminExists) { + const invitedUser = await this.db.getInviteTokenAndDelete(user.inviteToken); + user.role = invitedUser.role; + user.teamId = invitedUser.teamId; + } else { + // This is the first account, create JWT secret to use if one is not supplied by env + const jwtSecret = crypto.randomBytes(64).toString("hex"); + await this.db.updateAppSettings({ jwtSecret }); + } + + const newUser = await this.db.insertUser({ ...user }, file); + + this.logger.debug({ + message: "New user created", + service: SERVICE_NAME, + method: "registerUser", + details: newUser._id, + }); + + const userForToken = { ...newUser._doc }; + delete userForToken.profileImage; + delete userForToken.avatarImage; + + const appSettings = await this.settingsService.getSettings(); + + const token = this.issueToken(userForToken, appSettings); + + try { + const html = await this.emailService.buildEmail("welcomeEmailTemplate", { + name: newUser.firstName, + }); + this.emailService.sendEmail(newUser.email, "Welcome to Uptime Monitor", html).catch((error) => { + this.logger.warn({ + message: error.message, + service: SERVICE_NAME, + method: "registerUser", + stack: error.stack, + }); + }); + } catch (error) { + this.logger.warn({ + message: error.message, + service: SERVICE_NAME, + method: "registerUser", + stack: error.stack, + }); + } + + return { user: newUser, token }; + }; + + loginUser = async (email, password) => { + // Check if user exists + const user = await this.db.getUserByEmail(email); + // Compare password + const match = await user.comparePassword(password); + if (match !== true) { + throw createAuthError(this.stringService.authIncorrectPassword); + } + + // Remove password from user object. Should this be abstracted to DB layer? + const userWithoutPassword = { ...user._doc }; + delete userWithoutPassword.password; + delete userWithoutPassword.avatarImage; + + // Happy path, return token + const appSettings = await this.settingsService.getSettings(); + const token = this.issueToken(userWithoutPassword, appSettings); + // reset avatar image + userWithoutPassword.avatarImage = user.avatarImage; + return { user: userWithoutPassword, token }; + }; + + editUser = async (updates, file, currentUser) => { + // Change Password check + if (updates?.password && updates?.newPassword) { + // Get user's email + // Add user email to body for DB operation + updates.email = currentUser.email; + // Get user + const user = await this.db.getUserByEmail(currentUser.email); + // Compare passwords + const match = await user.comparePassword(updates?.password); + // If not a match, throw a 403 + // 403 instead of 401 to avoid triggering axios interceptor + if (!match) { + throw createError(this.stringService.authIncorrectPassword, 403); + } + // If a match, update the password + updates.password = updates.newPassword; + } + + const updatedUser = await this.db.updateUser({ userId: currentUser?._id, user: updates, file: file }); + return updatedUser; + }; + + checkSuperadminExists = async () => { + const superAdminExists = await this.db.checkSuperadmin(); + return superAdminExists; + }; + + requestRecovery = async (email) => { + const user = await this.db.getUserByEmail(email); + const recoveryToken = await this.db.requestRecoveryToken(email); + const name = user.firstName; + const { clientHost } = this.settingsService.getSettings(); + const url = `${clientHost}/set-new-password/${recoveryToken.token}`; + + const html = await this.emailService.buildEmail("passwordResetTemplate", { + name, + email, + url, + }); + const msgId = await this.emailService.sendEmail(email, "Checkmate Password Reset", html); + return msgId; + }; + + validateRecovery = async (recoveryToken) => { + await this.db.validateRecoveryToken(recoveryToken); + }; + + resetPassword = async (password, recoveryToken) => { + const user = await this.db.resetPassword(password, recoveryToken); + const appSettings = await this.settingsService.getSettings(); + const token = this.issueToken(user._doc, appSettings); + return { user, token }; + }; + + deleteUser = async (user) => { + const email = user?.email; + if (!email) { + throw new Error("No email in request"); + } + + const teamId = user?.teamId; + const userId = user?._id; + + if (!teamId) { + throw new Error("No team ID in request"); + } + + if (!userId) { + throw new Error("No user ID in request"); + } + + const roles = user?.role; + if (roles.includes("demo")) { + throw new Error("Demo user cannot be deleted"); + } + + // 1. Find all the monitors associated with the team ID if superadmin + const result = await this.db.getMonitorsByTeamId({ + teamId: teamId, + }); + + if (roles.includes("superadmin")) { + // 2. Remove all jobs, delete checks and alerts + result?.monitors.length > 0 && + (await Promise.all( + result.monitors.map(async (monitor) => { + await this.jobQueue.deleteJob(monitor); + }) + )); + } + // 6. Delete the user by id + await this.db.deleteUser(userId); + }; + + getAllUsers = async () => { + const users = await this.db.getAllUsers(); + return users; + }; + + getUserById = async (roles, userId) => { + const user = await this.db.getUserById(roles, userId); + return user; + }; + + editUserById = async (userId, user) => { + await this.db.editUserById(userId, user); + }; +} +export default UserService; diff --git a/server/validation/joi.js b/server/validation/joi.js index dcbf9a606..41b6fa471 100755 --- a/server/validation/joi.js +++ b/server/validation/joi.js @@ -58,8 +58,8 @@ const registrationBodyValidation = joi.object({ }); const editUserBodyValidation = joi.object({ - firstName: nameValidation.required(), - lastName: nameValidation.required(), + firstName: nameValidation.optional(), + lastName: nameValidation.optional(), profileImage: joi.any(), newPassword: joi.string().min(8).pattern(passwordPattern), password: joi.string().min(8).pattern(passwordPattern), @@ -73,7 +73,7 @@ const recoveryValidation = joi.object({ .required(), }); -const recoveryTokenValidation = joi.object({ +const recoveryTokenBodyValidation = joi.object({ recoveryToken: joi.string().required(), }); @@ -688,7 +688,7 @@ export { loginValidation, registrationBodyValidation, recoveryValidation, - recoveryTokenValidation, + recoveryTokenBodyValidation, newPasswordValidation, inviteRoleValidation, inviteBodyValidation,