diff --git a/Tombolo/.env.sample b/Tombolo/.env.sample index 69ccc276b..f63341b1c 100644 --- a/Tombolo/.env.sample +++ b/Tombolo/.env.sample @@ -29,17 +29,6 @@ DB_PORT=3306 DB_NAME=tombolo DB_HOSTNAME=localhost -# Authentication Configuration -APP_AUTH_METHOD= - -# if using auth service for authentication -AUTH_SERVICE_URL= -AUTHSERVICE_TOMBOLO_CLIENT_ID= - -# If using Azure AD for authentication -TENENT_ID= -CLIENT_ID= - # Email Configuration EMAIL_SMTP_HOST= EMAIL_PORT= @@ -49,6 +38,10 @@ EMAIL_SENDER=donotreply@tombolo.com ENCRYPTION_KEY= API_KEY_DURATION= +# Authentication and Authorization Configuration +JWT_SECRET= +JWT_REFRESH_SECRET= + # Logging Configuration NODE_LOG_LEVEL=http diff --git a/Tombolo/server/controllers/authController.js b/Tombolo/server/controllers/authController.js new file mode 100644 index 000000000..fbcd75eb5 --- /dev/null +++ b/Tombolo/server/controllers/authController.js @@ -0,0 +1,152 @@ +const bcrypt = require("bcryptjs"); +const jwt = require("jsonwebtoken"); +const {v4: uuidv4} = require("uuid"); + +const logger = require("../config/logger"); +const models = require("../models"); +const { + generateAccessToken, + generateRefreshToken, +} = require("../utils/authUtil"); + +const User = models.user; +const UserRoles = models.UserRoles; +const RoleTypes = models.RoleTypes; +const RefreshTokens = models.RefreshTokens; + +// Register basic user +const createBasicUser = async (req, res) => { + try { + const { deviceInfo = {} } = req.body; + const payload = req.body; + + // Hash password + const salt = bcrypt.genSaltSync(10); + payload.hash = bcrypt.hashSync(req.body.password, salt); + + // Save user to DB + const user = await User.create(payload); + + // remove hash from user object + const userObj = user.toJSON(); + delete userObj.hash; + + // Create token id + const tokenId = uuidv4(); + + // Create access jwt + userObj.token = generateAccessToken({ ...userObj, tokenId }); + + // Generate refresh token + const refreshToken = generateRefreshToken({ tokenId }); + + // Save refresh token to DB + const { iat, exp } = jwt.decode(refreshToken); + + // Save refresh token in DB + await RefreshTokens.create({ + id: tokenId, + userId: user.id, + token: refreshToken, + deviceInfo, + metaData: {}, + iat: new Date(iat * 1000), + exp: new Date(exp * 1000), + }); + + // Send response + res.status(201).json({ + success: true, + message: "User created successfully", + data: {...userObj, "UserRoles": [],}, + }); + } catch (err) { + logger.error(`Create user: ${err.message}`); + res + .status(err.status || 500) + .json({ success: false, message: err.message }); + } +}; + +//Login Basic user +const loginBasicUser = async (req, res) => { + try { + const { email, password, deviceInfo = {} } = req.body; + + // find user - include user roles from UserRoles table + const user = await User.findOne({ + where: { email }, + include: [ + { + model: UserRoles, + attributes: ["id"], + as: "roles", + include: [ + { + model: RoleTypes, + as: "role_details", + attributes: ["id", "roleName"], + }, + ], + }, + ], + }); + + // User with the given email does not exist + if (!user) { + logger.error(`Login : User with email ${email} does not exist`); + return res + .status(404) + .json({ success: false, message: "User not found" }); + } + + //Compare password + if (!bcrypt.compareSync(password, user.hash)) { + logger.error(`Login : Invalid password for user with email ${email}`); + return res + .status(401) + .json({ success: false, message: "Invalid password" }); + } + + // Remove hash from use object + const userObj = user.toJSON(); + delete userObj.hash; + + // Create token id + const tokenId = uuidv4(); + + // Create access jwt + userObj.token = generateAccessToken({ ...userObj, tokenId }); + + // Generate refresh token + const refreshToken = generateRefreshToken({ tokenId }); + + // Save refresh token to DB + const { iat, exp } = jwt.decode(refreshToken); + + // Save refresh token in DB + await RefreshTokens.create({id: tokenId, + userId: user.id, + token: refreshToken, + deviceInfo, + metaData: {}, + iat : new Date(iat * 1000), + exp : new Date(exp * 1000)}); + + // Success response + res.status(200).json({ success: true, message: "User logged in successfully", data: userObj }); + } catch (err) { + logger.error(`Login user: ${err.message}`); + res.status(err.status || 500).json({ success: false, message: err.message }); + } +}; + +// Register OAuth user + +// Login OAuth user + +//Exports +module.exports = { + createBasicUser, + loginBasicUser, +}; diff --git a/Tombolo/server/middlewares/authMiddleware.js b/Tombolo/server/middlewares/authMiddleware.js new file mode 100644 index 000000000..2916abfa0 --- /dev/null +++ b/Tombolo/server/middlewares/authMiddleware.js @@ -0,0 +1,95 @@ +// Validate add user inputs using express validator +const { body, param, validationResult } = require("express-validator"); +const logger = require("../config/logger"); + +// Validate registration payload +const validateNewUserPayload = [ + body("registrationMethod") + .isString() + .notEmpty() + .withMessage("Registration method is required") + .isIn(["traditional", "microsoft"]) + .withMessage( + "Registration method must be either 'traditional' or 'microsoft'" + ), + body("firstName") + .isString() + .notEmpty() + .withMessage("First name is required") + .isLength({ min: 2, max: 50 }) + .withMessage("First name must be between 2 and 50 characters"), + body("lastName") + .isString() + .notEmpty() + .withMessage("Last name is required") + .isLength({ min: 2, max: 50 }) + .withMessage("Last name must be between 2 and 50 characters"), + body("email") + .isEmail() + .withMessage("Email address is not valid") + .notEmpty() + .withMessage("Email is required") + .isLength({ max: 100 }) + .withMessage("Email must be less than 100 characters"), + body("password") + .if(body("registrationMethod").equals("traditional")) + .isString() + .notEmpty() + .withMessage("Password is required") + .isLength({ min: 8 }) + .withMessage("Password must be at least 8 characters long") + .matches(/[A-Z]/) + .withMessage("Password must contain at least one uppercase letter") + .matches(/[a-z]/) + .withMessage("Password must contain at least one lowercase letter") + .matches(/[0-9]/) + .withMessage("Password must contain at least one number") + .matches(/[\W_]/) + .withMessage("Password must contain at least one special character"), + body("metaData") + .isObject() + .optional() + .withMessage("Meta data must be an object if provided"), + (req, res, next) => { + const errors = validationResult(req).array(); + const errorString = errors.map((e) => e.msg).join(", "); + if (errors.length > 0) { + logger.error(`Update user: ${errorString}`); + return res.status(400).json({ success: false, message: errorString }); + } + next(); + }, +]; + + +// Validate login payload +const validateLoginPayload = [ + body("email") + .isEmail() + .withMessage("Email address is not valid") + .notEmpty() + .withMessage("Email is required") + .isLength({ max: 100 }) + .withMessage("Email must be less than 100 characters"), + body("password") + .isString() + .notEmpty() + .withMessage("Password is required"), + (req, res, next) => { + const errors = validationResult(req).array(); + const errorString = errors.map((e) => e.msg).join(", "); + if (errors.length > 0) { + logger.error(`Login: ${errorString}`); + return res.status(400).json({ success: false, message: errorString }); + } + next(); + }, +]; + + + +// Exports +module.exports = { + validateNewUserPayload, + validateLoginPayload, +}; diff --git a/Tombolo/server/migrations/20240919152709-create-refresh-token-table.js b/Tombolo/server/migrations/20240919152709-create-refresh-token-table.js new file mode 100644 index 000000000..2751e8053 --- /dev/null +++ b/Tombolo/server/migrations/20240919152709-create-refresh-token-table.js @@ -0,0 +1,70 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable("refresh_tokens", { + id: { + primaryKey: true, + type: Sequelize.UUID, + allowNull: false, + }, + userId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "users", // Name of the users model + key: "id", + }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + token: { + type: Sequelize.STRING, + allowNull: false, + }, + deviceInfo: { + type: Sequelize.JSON, + allowNull: false, + }, + iat: { + type: Sequelize.DATE, + allowNull: false, + }, + exp:{ + type: Sequelize.DATE, + allowNull: false, + }, + revoked: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + revokedAt: { + type: Sequelize.DATE, + allowNull: true, + }, + metaData: { + type: Sequelize.JSON, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"), + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + }, + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable("refresh_tokens"); + }, +}; diff --git a/Tombolo/server/models/refresh_tokens.js b/Tombolo/server/models/refresh_tokens.js new file mode 100644 index 000000000..ab1ab85d6 --- /dev/null +++ b/Tombolo/server/models/refresh_tokens.js @@ -0,0 +1,61 @@ +// Model for storing refresh token [ id - uuid , userId - uuid, token - string, device Info - json, timestamp, paranoid, freeze table name ] +"use strict"; +module.exports = (sequelize, DataTypes) => { + const RefreshTokens = sequelize.define("RefreshTokens", { + id: { + primaryKey: true, + type: DataTypes.UUID, + allowNull: false, + }, + userId: { + type: DataTypes.UUID, + allowNull: false, + }, + token: { + type: DataTypes.STRING, + allowNull: false, + }, + deviceInfo: { + type: DataTypes.JSON, + allowNull: false, + }, + iat:{ + type: DataTypes.DATE, + allowNull: false, + }, + exp: { + type: DataTypes.DATE, + allowNull: false, + }, + revoked:{ + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + revokedAt:{ + type: DataTypes.DATE, + allowNull: true, + }, + metaData: { + type: DataTypes.JSON, + allowNull: true, + } + }, + { + tableName: 'refresh_tokens', + freezeTableName: true, + timeStamps: true, + paranoid: true + } + ); + // Associations + RefreshTokens.associate = function(models) { + RefreshTokens.belongsTo(models.user, { + foreignKey: "userId", + onDelete: "CASCADE", + hooks: true, + }); + }; + + return RefreshTokens; +}; \ No newline at end of file diff --git a/Tombolo/server/models/role_types.js b/Tombolo/server/models/role_types.js index 3af6dad32..11d9abf77 100644 --- a/Tombolo/server/models/role_types.js +++ b/Tombolo/server/models/role_types.js @@ -1,6 +1,8 @@ // role types model definition 'use strict'; module.exports = (sequelize, DataTypes) => { + + // Define the RoleTypes const RoleTypes = sequelize.define("RoleTypes",{ id: { primaryKey: true, @@ -21,17 +23,26 @@ module.exports = (sequelize, DataTypes) => { tableName: 'role_types', freezeTableName: true, timeStamps: true, - paranoid: true + paranoid: true, + hooks: { + beforeBulkDestroy: async(roleType, options) => { + // Deleted associated user roles + const UserRoles = sequelize.models.UserRoles; + await UserRoles.destroy({where: {roleId: roleType.where.id,}}); + }, + } } ); + + // Associations RoleTypes.associate = function(models) { - // associations can be defined here - RoleTypes.belongsToMany(models.user, { - through: models.UserRoles, - foreignKey: "roleId", - onDelete: "CASCADE", + RoleTypes.hasMany(models.UserRoles, { + foreignKey: 'roleId', + onDelete: 'CASCADE', + onUpdate: 'CASCADE', }); }; + return RoleTypes; }; \ No newline at end of file diff --git a/Tombolo/server/models/user.js b/Tombolo/server/models/user.js index 53213478f..745e3aab2 100644 --- a/Tombolo/server/models/user.js +++ b/Tombolo/server/models/user.js @@ -1,4 +1,5 @@ "use strict"; + module.exports = (sequelize, DataTypes) => { const user = sequelize.define( "user", @@ -41,13 +42,14 @@ module.exports = (sequelize, DataTypes) => { allowNull: false, defaultValue: false, }, - verifiedAt:{ + verifiedAt: { type: DataTypes.DATE, allowNull: true, }, registrationStatus: { type: DataTypes.STRING, allowNull: false, + defaultValue: "pending", validate: { isIn: [["pending", "active", "revoked"]], // Must be one of these values }, @@ -65,22 +67,50 @@ module.exports = (sequelize, DataTypes) => { tableName: "users", timestamps: true, paranoid: true, + + // For paranoid tables, hooks are required to delete data from associated tables + hooks: { + beforeBulkDestroy: async (user, options) => { + // Delete refresh tokens + const RefreshTokens = sequelize.models.RefreshTokens; + await RefreshTokens.destroy({ + where: {userId: user.where.id,}, + }); + + // Delete user roles + const UserRoles = sequelize.models.UserRoles; + await UserRoles.destroy({ + where: {userId: user.where.id,}, + }); + }, + }, } ); + // Associations user.associate = function (models) { - // associations can be defined here - user.belongsToMany(models.RoleTypes, { - through: models.UserRoles, + user.hasMany(models.UserRoles, { foreignKey: "userId", onDelete: "CASCADE", + onUpdate: "CASCADE", + as: "roles", }); //user to application relation user.hasMany(models.user_application, { foreignKey: "user_id", + as: "applications", // Alias onDelete: "CASCADE", }); + + // User to refresh token + user.hasMany(models.RefreshTokens, { + foreignKey: "userId", + onDelete: "CASCADE", + hooks: true, + }); }; + + return user; }; diff --git a/Tombolo/server/models/user_roles.js b/Tombolo/server/models/user_roles.js index 93acdeb02..ab7c938ff 100644 --- a/Tombolo/server/models/user_roles.js +++ b/Tombolo/server/models/user_roles.js @@ -1,6 +1,7 @@ // user and role types mapping table 'use strict'; module.exports = (sequelize, DataTypes) => { + // Define the UserRoles const UserRoles = sequelize.define("UserRoles",{ id: { primaryKey: true, @@ -11,10 +12,18 @@ module.exports = (sequelize, DataTypes) => { userId:{ type: DataTypes.UUID, allowNull: false, + references: { + model: 'users', // Name of the users table + key: 'id', + } }, roleId:{ type: DataTypes.UUID, allowNull: false, + references: { + model: 'role_types', // Name of the role_types table + key: 'id', + } }, createdBy:{ type: DataTypes.UUID, @@ -29,8 +38,13 @@ module.exports = (sequelize, DataTypes) => { } ); + + // Associations UserRoles.associate = function(models) { + UserRoles.belongsTo(models.user, {foreignKey: 'userId'}); + UserRoles.belongsTo(models.RoleTypes, {foreignKey: 'roleId', as: "role_details"}); }; + return UserRoles; }; diff --git a/Tombolo/server/routes/authRoutes.js b/Tombolo/server/routes/authRoutes.js new file mode 100644 index 000000000..4b4fa6659 --- /dev/null +++ b/Tombolo/server/routes/authRoutes.js @@ -0,0 +1,25 @@ +const express = require("express"); +const router = express.Router(); + +// Import user middleware +const { + validateNewUserPayload, + validateLoginPayload, +} = require("../middlewares/authMiddleware"); + +// Import user controller +const { + createBasicUser, + loginBasicUser, +} = require("../controllers/authController"); + + +// Routes +router.post("/registerBasicUser", validateNewUserPayload, createBasicUser); // Create a new user ( Traditional ) +router.post("/loginBasicUser", validateLoginPayload, loginBasicUser ); // Login user ( Traditional ) +// router.post("/registerOAuthUser" ); // Register user ( OAuth ) +// router.post("/loginOAuthUser" ); // Login user ( OAuth ) +// Forgot password route + + +module.exports = router; \ No newline at end of file diff --git a/Tombolo/server/server.js b/Tombolo/server/server.js index 4f800a737..5d0f43961 100644 --- a/Tombolo/server/server.js +++ b/Tombolo/server/server.js @@ -53,6 +53,7 @@ if (process.env.APP_AUTH_METHOD === "azure_ad") { } /* ROUTES */ +const auth = require("./routes/authRoutes"); const job = require("./routes/job/read"); const bree = require("./routes/bree/read"); const ldap = require("./routes/ldap/read"); @@ -101,6 +102,7 @@ app.use((req, res, next) => { // Use compression to reduce the size of the response body and increase the speed of a web application app.use(compression()); +app.use("/api/auth", auth); app.use("/api/user", userRead); app.use("/api/updateNotification", updateNotifications); app.use("/api/status", status); diff --git a/Tombolo/server/utils/authUtil.js b/Tombolo/server/utils/authUtil.js new file mode 100644 index 000000000..2f37048ce --- /dev/null +++ b/Tombolo/server/utils/authUtil.js @@ -0,0 +1,63 @@ +const jwt = require("jsonwebtoken"); + +const model = require("../models"); +const User = model.user; +const UserRoles = model.UserRoles; + +// Generate access token +const generateAccessToken = (user) => { + return jwt.sign( + user, + process.env.JWT_SECRET, + { expiresIn: "1h" } + ); +} + +// Generate refresh token +const generateRefreshToken = (tokenId) => { + return jwt.sign( + tokenId, + process.env.JWT_REFRESH_SECRET, + { expiresIn: "7d" } + ); +} + +// Verify token +const verifyToken = (token) => { + return jwt.verify(token, process.env.JWT_SECRET); +} + +// Verify refresh token +const verifyRefreshToken = (token) => { + return jwt.verify(token, process.env.JWT_REFRESH_SECRET); +} + +// Refresh access token +const refreshAccessToken = async (refreshToken) => { + const decoded = verifyRefreshToken(refreshToken); + const { id } = decoded; // Extract user ID from decoded payload + + // Fetch user roles from database + const user = await User.findOne({ + where: { id }, + include: UserRoles + }) + + // Get user roles + const userRoles = await UserRoles.findAll({ + where: { userId: user.id }, + }); + + // Generate new access token + const accessToken = generateAccessToken(user); + +}; + +//Exports +module.exports = { + generateAccessToken, + generateRefreshToken, + verifyToken, + verifyRefreshToken, + refreshAccessToken +}; \ No newline at end of file