diff --git a/Client/src/App.jsx b/Client/src/App.jsx index 6f64d45da..16774c60a 100644 --- a/Client/src/App.jsx +++ b/Client/src/App.jsx @@ -34,7 +34,7 @@ function App() { } /> } /> } /> - } /> + } /> } diff --git a/Client/src/Pages/SetNewPassword/index.jsx b/Client/src/Pages/SetNewPassword/index.jsx index 31e213c10..fc453cde1 100644 --- a/Client/src/Pages/SetNewPassword/index.jsx +++ b/Client/src/Pages/SetNewPassword/index.jsx @@ -6,8 +6,10 @@ import PasswordTextField from "../../Components/TextFields/Password/PasswordText import Check from "../../Components/Check/Check"; import Button from "../../Components/Button"; import LeftArrow from "../../assets/Images/arrow-left.png"; +import { useParams } from "react-router-dom"; const SetNewPassword = () => { + const { token } = useParams(); return (
diff --git a/README.md b/README.md index 979731f50..02890e230 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ BlueWave uptime monitoring application - POST [/api/v1/auth/register](#post-register) - POST [/api/v1/auth/login](#post-login) - POST [/api/v1/auth/user/{userId}](#post-auth-user-edit-id) + - POST [/api/v1/auth/recovery/request](#post-auth-recovery-request-id) + - POST [/api/v1/auth/recovery/validate](#post-auth-recovery-validate-id) + - POST [/api/v1/auth/recovery/reset](#post-auth-recovery-reset-id) ###### Monitors - GET [/api/v1/monitors](#get-monitors) - GET [/api/v1/monitor/{id}](#get-monitor-id) @@ -80,6 +83,7 @@ Configure the server with the following environmental variables: | ENV Variable Name | Required/Optional | Type | Description | Accepted Values | | -------------------- | ----------------- | --------- | ------------------------------------------------------------------------------------------- | ------------------- | +| CLIENT_HOST | Required | `string` | Frontend Host | | | JWT_SECRET | Required | `string` | JWT secret | | | DB_TYPE | Optional | `string` | Specify DB to use | `MongoDB \| FakeDB` | | DB_CONNECTION_STRING | Required | `string` | Specifies URL for MongoDB Database | | @@ -382,6 +386,166 @@ curl --request POST \ +
+POST/api/v1/auth/recovery/request + +###### Method/Headers + +> | Method/Headers | Value | +> | -------------- | ----- | +> | Method | POST | + +##### Body + +> | Name | Type | Notes | +> | ----- | -------- | ------------ | +> | email | `string` | User's email | + +###### Response Payload + +> | Type | Notes | +> | --------------- | --------------------------------------- | +> | `RecoveryToken` | Returns a recovery token if email found | + +##### Sample CURL request + +``` +curl --request POST \ + --url http://localhost:5000/api/v1/auth/recovery/request \ + --header 'Content-Type: application/json' \ + --data '{ + "email" : "ajhollid@gmail.com" +}' +``` + +###### Sample Response + +```json +{ + "success": true, + "msg": "Created recovery token", + "data": { + "email": "your_email@gmail.com", + "token": "f519da5e4a9be40cfc3c0fde97e60c0e6d17bdaa613f5ba537a45073f3865193", + "_id": "6668878263587f30748e968e", + "expiry": "2024-06-11T17:21:06.984Z", + "createdAt": "2024-06-11T17:21:06.985Z", + "updatedAt": "2024-06-11T17:21:06.985Z", + "__v": 0 + } +} +``` + +
+ +
+POST/api/v1/auth/recovery/validate + +###### Method/Headers + +> | Method/Headers | Value | +> | -------------- | ----- | +> | Method | POST | + +##### Body + +> | Name | Type | Notes | +> | ------------- | -------- | ----------------------------------- | +> | recoveryToken | `string` | Token issued in `/recovery/request` | + +###### Response Payload + +> | Type | Notes | +> | --------------- | -------------------------- | +> | `RecoveryToken` | Returns the recovery token | + +##### Sample CURL request + +``` +curl --request POST \ + --url http://localhost:5000/api/v1/auth/recovery/validate \ + --header 'Content-Type: application/json' \ + --data '{ + "recoveryToken" : "f519da5e4a9be40cfc3c0fde97e60c0e6d17bdaa613f5ba537a45073f3865193" +}' +``` + +###### Sample Response + +```json +{ + "success": true, + "msg": "Token is valid", + "data": { + "_id": "6668894263587f30748e969a", + "email": "ajhollid@gmail.com", + "token": "457d9926b24dedf613f120eeb524ef00ac45b3f0fc5c70bd25b1cc8aa83a64a0", + "expiry": "2024-06-11T17:28:34.349Z", + "createdAt": "2024-06-11T17:28:34.349Z", + "updatedAt": "2024-06-11T17:28:34.349Z", + "__v": 0 + } +} +``` + +
+ +
+POST/api/v1/auth/recovery/reset + +###### Method/Headers + +> | Method/Headers | Value | +> | -------------- | ----- | +> | Method | POST | + +##### Body + +> | Name | Type | Notes | +> | ------------- | -------- | --------------------------------------------- | +> | recoveryToken | `string` | Token issued returned by `/recovery/validate` | +> | password | `string` | User's new password` | + +###### Response Payload + +> | Type | Notes | +> | ------ | ------------------------ | +> | `User` | Returns the updated user | + +##### Sample CURL request + +``` +curl --request POST \ + --url http://localhost:5000/api/v1/auth/recovery/reset \ + --header 'Content-Type: application/json' \ + --data '{ + "recoveryToken" : "f519da5e4a9be40cfc3c0fde97e60c0e6d17bdaa613f5ba537a45073f3865193", + "password": "testtest" +}' +``` + +###### Sample Response + +```json +{ + "success": true, + "msg": "Password reset", + "data": { + "_id": "66675891cb17336d84c25d9f", + "firstname": "User First Name", + "lastname": "User Last Name", + "email": "your_email@gmail.com", + "isActive": true, + "isVerified": false, + "createdAt": "2024-06-10T19:48:33.863Z", + "updatedAt": "2024-06-11T17:21:22.289Z", + "__v": 0 + } +} +``` + +
+ --- ###### Monitors diff --git a/Server/controllers/authController.js b/Server/controllers/authController.js index 8c242af67..6c035aa5c 100644 --- a/Server/controllers/authController.js +++ b/Server/controllers/authController.js @@ -87,7 +87,6 @@ const registerController = async (req, res, next) => { * @param {Express.Request} req * @param {Express.Response} res * @returns {Promise} - * @throws {Error} */ const loginController = async (req, res, next) => { try { @@ -97,10 +96,6 @@ const loginController = async (req, res, next) => { // Check if user exists const user = await req.db.getUserByEmail(req, res); - // If user not found, throw an error - if (!user) { - throw new Error("User not found!"); - } // Compare password const match = await user.comparePassword(req.body.password); if (!match) { @@ -145,7 +140,7 @@ const userEditController = async (req, res, next) => { try { const updatedUser = await req.db.updateUser(req, res); - res + return res .status(200) .json({ success: true, msg: "User updated", data: updatedUser }); } catch (error) { @@ -155,4 +150,87 @@ const userEditController = async (req, res, next) => { } }; -module.exports = { registerController, loginController, userEditController }; +/** + * Returns a recovery token + * @async + * @param {Express.Request} req + * @property {Object} req.body + * @property {string} req.body.email + * @param {Express.Response} res + * @returns {Promise} + */ +const recoveryRequestController = async (req, res, next) => { + try { + const user = await req.db.getUserByEmail(req, res); + if (user) { + const recoveryToken = await req.db.requestRecoveryToken(req, res); + await sendEmail( + [req.body.email], + "Uptime Monitor Password Recovery", + `Click here to reset your password`, + `Recovery token: ${recoveryToken.token}` + ); + return res.status(200).json({ + success: true, + msg: "Created recovery token", + }); + } + // TODO Email token to user + } catch (error) { + error.service = SERVICE_NAME; + next(error); + return; + } +}; + +/** + * Returns a recovery token + * @async + * @param {Express.Request} req + * @property {Object} req.body + * @property {string} req.body.token + * @param {Express.Response} res + * @returns {Promise} + */ +const validateRecoveryTokenController = async (req, res, next) => { + try { + const recoveryToken = await req.db.validateRecoveryToken(req, res); + // TODO Redirect user to reset password after validating token + return res.status(200).json({ + success: true, + msg: "Token is valid", + }); + } catch (error) { + error.service = SERVICE_NAME; + next(error); + return; + } +}; + +/** + * Returns an updated user + * @async + * @param {Express.Request} req + * @property {Object} req.body + * @property {string} req.body.token + * @property {string} req.body.password + * @param {Express.Response} res + * @returns {Promise} + */ +const resetPasswordController = async (req, res, next) => { + try { + user = await req.db.resetPassword(req, res); + res.status(200).json({ success: true, msg: "Password reset", data: user }); + } catch (error) { + error.service = SERVICE_NAME; + next(error); + } +}; +module.exports = { + registerController, + loginController, + userEditController, + recoveryRequestController, + validateRecoveryTokenController, + resetPasswordController, +}; diff --git a/Server/db/MongoDB.js b/Server/db/MongoDB.js index aabd3c384..f64059342 100644 --- a/Server/db/MongoDB.js +++ b/Server/db/MongoDB.js @@ -3,10 +3,8 @@ const mongoose = require("mongoose"); const UserModel = require("../models/user"); const Check = require("../models/Check"); const Alert = require("../models/Alert"); - -const verifyId = (userId, monitorId) => { - return userId.toString() === monitorId.toString(); -}; +const RecoveryToken = require("../models/RecoveryToken"); +const crypto = require("crypto"); const connect = async () => { try { @@ -44,6 +42,7 @@ const insertUser = async (req, res) => { * Get User by Email * Gets a user by Email. Not sure if we'll ever need this except for login. * If not needed except for login, we can move password comparison here + * Throws error if user not found * @async * @param {Express.Request} req * @param {Express.Response} res @@ -52,10 +51,14 @@ const insertUser = async (req, res) => { */ const getUserByEmail = async (req, res) => { try { - // Returns null if no user is found // Need the password to be able to compare, removed .select() // We can strip the hash before returing the user - return await UserModel.findOne({ email: req.body.email }); + const user = await UserModel.findOne({ email: req.body.email }); + if (user) { + return user; + } else { + throw new Error("User not found"); + } } catch (error) { throw error; } @@ -86,6 +89,68 @@ const updateUser = async (req, res) => { } }; +/** + * Request a recovery token + * @async + * @param {Express.Request} req + * @param {Express.Response} res + * @returns {Promise} + * @throws {Error} + */ +const requestRecoveryToken = async (req, res) => { + try { + // Delete any existing tokens + await RecoveryToken.deleteMany({ email: req.body.email }); + let recoveryToken = new RecoveryToken({ + email: req.body.email, + token: crypto.randomBytes(32).toString("hex"), + }); + await recoveryToken.save(); + return recoveryToken; + } catch (error) { + throw error; + } +}; + +const validateRecoveryToken = async (req, res) => { + try { + const candidateToken = req.body.recoveryToken; + const recoveryToken = await RecoveryToken.findOne({ + token: candidateToken, + }); + if (recoveryToken !== null) { + return recoveryToken; + } else { + throw new Error("Token not found"); + } + } catch (error) { + throw error; + } +}; + +const resetPassword = async (req, res) => { + try { + const newPassword = req.body.password; + + // Validate token again + const recoveryToken = await validateRecoveryToken(req, res); + const user = await UserModel.findOne({ email: recoveryToken.email }); + + if (user !== null) { + user.password = newPassword; + await user.save(); + await RecoveryToken.deleteMany({ email: recoveryToken.email }); + // Fetch the user again without the password + const userWithoutPassword = await UserModel.findOne({ + email: recoveryToken.email, + }).select("-password"); + return userWithoutPassword; + } else { + throw new Error("User not found"); + } + } catch (error) {} +}; + //**************************************** // Monitors //**************************************** @@ -393,6 +458,9 @@ module.exports = { insertUser, getUserByEmail, updateUser, + requestRecoveryToken, + validateRecoveryToken, + resetPassword, getAllMonitors, getMonitorById, getMonitorsByUserId, diff --git a/Server/models/RecoveryToken.js b/Server/models/RecoveryToken.js new file mode 100644 index 000000000..2b67a89e1 --- /dev/null +++ b/Server/models/RecoveryToken.js @@ -0,0 +1,25 @@ +const mongoose = require("mongoose"); + +const RecoveryTokenSchema = mongoose.Schema( + { + email: { + type: String, + required: true, + unique: true, + }, + token: { + type: String, + required: true, + }, + expiry: { + type: Date, + default: Date.now, + expires: 600, + }, + }, + { + timestamps: true, + } +); + +module.exports = mongoose.model("RecoveryToken", RecoveryTokenSchema); diff --git a/Server/routes/authRoute.js b/Server/routes/authRoute.js index 637e4bf5e..1049659fa 100644 --- a/Server/routes/authRoute.js +++ b/Server/routes/authRoute.js @@ -5,10 +5,19 @@ const { registerController, loginController, userEditController, + recoveryRequestController, + validateRecoveryTokenController, + resetPasswordController, } = require("../controllers/authController"); +//Auth routes router.post("/register", registerController); router.post("/login", loginController); router.post("/user/:userId", verifyJWT, userEditController); +//Recovery routes +router.post("/recovery/request", recoveryRequestController); +router.post("/recovery/validate", validateRecoveryTokenController); +router.post("/recovery/reset/", resetPasswordController); + module.exports = router; diff --git a/Server/service/jobQueue.js b/Server/service/jobQueue.js index 84241f443..5a9fbc6d5 100644 --- a/Server/service/jobQueue.js +++ b/Server/service/jobQueue.js @@ -14,7 +14,6 @@ class JobQueue { * @throws {Error} */ constructor() { - console.log(process.env.REDIS_PORT); this.queue = new Queue(QUEUE_NAME, { connection, }); diff --git a/Server/utils/sendEmail.js b/Server/utils/sendEmail.js index d60900383..306bec2c5 100644 --- a/Server/utils/sendEmail.js +++ b/Server/utils/sendEmail.js @@ -1,7 +1,7 @@ -const sgMail = require('@sendgrid/mail') -const logger = require('./logger') -sgMail.setApiKey(process.env.SENDGRID_API_KEY) -const SERVICE_NAME = 'Email_Service'; +const sgMail = require("@sendgrid/mail"); +const logger = require("./logger"); +sgMail.setApiKey(process.env.SENDGRID_API_KEY); +const SERVICE_NAME = "Email_Service"; /** * @async * @function @@ -12,25 +12,34 @@ const SERVICE_NAME = 'Email_Service'; * @example * await sendEmail(['veysel@bluewavelabs.ca','alex@bluewavelabs.ca'],'Testing Email Service','

BlueWaveLabs

','Testing Email Service') */ -const sendEmail = async (receivers,subject, contentHTML, contentText = null ) => { - const msg = { - to: receivers, - from: { - name: 'Uptime System', - email: process.env.SYSTEM_EMAIL_ADDRESS // must be verified email by sendgrid - }, - subject: subject, - text: contentText || contentHTML, - html: contentHTML, - } - try { - await sgMail.send(msg); - logger.info(`Emails sent to receivers:${receivers} with the subject:${subject}`,{service:SERVICE_NAME}) - } catch (error) { - logger.error(`Sending Email action failed, ERROR:${error}`, { service: SERVICE_NAME }); - } -} +const sendEmail = async ( + receivers, + subject, + contentHTML, + contentText = null +) => { + const msg = { + to: receivers, + from: { + name: "Uptime System", + email: process.env.SYSTEM_EMAIL_ADDRESS, // must be verified email by sendgrid + }, + subject: subject, + text: contentText || contentHTML, + html: contentHTML, + }; + try { + await sgMail.send(msg); + logger.info( + `Emails sent to receivers:${receivers} with the subject:${subject}`, + { service: SERVICE_NAME } + ); + } catch (error) { + logger.error(`Sending Email action failed, ERROR:${error}`, { + service: SERVICE_NAME, + }); + throw error; + } +}; - - -module.exports = {sendEmail} \ No newline at end of file +module.exports = { sendEmail };