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 };