From ae24083c4d2f168a89aecba39eec0dfe4971b93f Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Thu, 26 Dec 2024 13:15:17 -0800 Subject: [PATCH 01/24] feat(backend/db): Add database connection and initialization module - Added `connect` function in `db.js` to establish a new database connection if not already connected. - Added `disconnect` function in `db.js` to close the active database connection. - Exported `connect` and `disconnect` functions for use in other modules. --- backend/src/db/db.js | 51 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 backend/src/db/db.js diff --git a/backend/src/db/db.js b/backend/src/db/db.js new file mode 100644 index 0000000..197306b --- /dev/null +++ b/backend/src/db/db.js @@ -0,0 +1,51 @@ +/** + * @fileoverview Database Connection + * @description Database connection and initialization. + */ + +const mongoose = require('mongoose'); + +let isConnected = false; + +/** + * @function connect - Connect to the database. + * @returns {Promise} - A promise that resolves when the connection is established. + * @throws {Error} - Throws an error if the connection fails. + */ +const connect = async () => { + if (isConnected) { + console.log('Using existing database connection'); + return Promise.resolve(); + } + + console.log('Using new database connection'); + const db = await mongoose.connect(process.env.MONGODB_URI); + + isConnected = db.connections[0].readyState; + + return Promise.resolve(); +}; + +/** + * @function disconnect - Disconnect from the database. + * @returns {Promise} - A promise that resolves when the connection is closed. + * @throws {Error} - Throws an error if the disconnection fails. + */ +const disconnect = async () => { + if (!isConnected) { + console.log('No active database connection to close'); + return; + } + + console.log('Closing database connection'); + await mongoose.disconnect(); + + isConnected = false; + + return; +}; + +module.exports = { + connect, + disconnect, +}; From d35dcec590b7064c84f56cb54e5bccfab174d466 Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Thu, 26 Dec 2024 13:49:20 -0800 Subject: [PATCH 02/24] feat(backend): Connect and disconnect from the database in server.js - Added database connection initialization in `server.js` using `db.connect()`. - Ensured graceful shutdown of the server by disconnecting from the database on SIGTERM and SIGINT signals using `db.disconnect()`. --- backend/server.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/server.js b/backend/server.js index 525e90f..ed6cb08 100644 --- a/backend/server.js +++ b/backend/server.js @@ -8,6 +8,7 @@ require('dotenv').config(); // Load required modules const express = require('express'); +const db = require('./src/db/db'); const DEFAULT_PORT = 5000; const port = process.env.PORT || DEFAULT_PORT; @@ -18,6 +19,9 @@ const app = express(); // Trust the first proxy app.set('trust proxy', 1); +// Connect to the database +db.connect(); + // Start the server const server = app.listen(port, host, () => { console.log(`Backend server is running on http://${host}:${port}`); @@ -38,6 +42,7 @@ process.on('SIGINT', async () => { }); app.close = async () => { + await db.disconnect(); server.close(); }; From be76d36f847ec3dff983bb8bc650a7a5aeaf29af Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Thu, 26 Dec 2024 14:18:42 -0800 Subject: [PATCH 03/24] feat(project): Refactor server startup to use child processes for frontend and backend --- backend/src/db/db.js | 2 +- index.js | 24 +++++++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/backend/src/db/db.js b/backend/src/db/db.js index 197306b..9ffe341 100644 --- a/backend/src/db/db.js +++ b/backend/src/db/db.js @@ -37,7 +37,7 @@ const disconnect = async () => { return; } - console.log('Closing database connection'); + console.log('Closing database connection...'); await mongoose.disconnect(); isConnected = false; diff --git a/index.js b/index.js index 6a83d6e..a96f7dd 100644 --- a/index.js +++ b/index.js @@ -6,6 +6,24 @@ // Log starting message console.log('Starting application...'); -// Load and run both frontend and backend servers -require('./frontend/server'); -require('./backend/server'); +const { spawn } = require('child_process'); +const path = require('path'); + +/** + * @function startServer - Start a server process. + * @param {string} scriptPath - The path to the server script. + * @param {string} cwd - The current working directory for the server. + */ +function startServer(scriptPath, cwd) { + const server = spawn('node', [scriptPath], { stdio: 'inherit', cwd }); + + server.on('close', (code) => { + console.log(`${scriptPath} process exited with code ${code}`); + }); +} + +// Start backend server +startServer('server.js', path.join(__dirname, 'backend')); + +// Start frontend server +startServer('server.js', path.join(__dirname, 'frontend')); From 1e151c69a1fa2b89b8c5ab17e87489b52cfd1205 Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Thu, 26 Dec 2024 14:25:07 -0800 Subject: [PATCH 04/24] feat(backend): Add body-parser middleware for JSON request handling --- backend/server.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/server.js b/backend/server.js index ed6cb08..1fc8622 100644 --- a/backend/server.js +++ b/backend/server.js @@ -8,6 +8,7 @@ require('dotenv').config(); // Load required modules const express = require('express'); +const bodyParser = require('body-parser'); const db = require('./src/db/db'); const DEFAULT_PORT = 5000; @@ -22,6 +23,9 @@ app.set('trust proxy', 1); // Connect to the database db.connect(); +// Middleware setup +app.use(bodyParser.json()); + // Start the server const server = app.listen(port, host, () => { console.log(`Backend server is running on http://${host}:${port}`); From b51fd9cdccd6a1bf969c1efb10b8764058e47800 Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Thu, 26 Dec 2024 14:35:50 -0800 Subject: [PATCH 05/24] feat(backend/middleware): Add request details preprocessing middleware - Added middleware to preprocess request details in `preprocessRequestDetailsMiddleware.js`. - Ensured the request IP is set to 'unknown' if not provided. - Populated the User-Agent header with 'unknown' if not present in the request. - Exported the middleware for use in other modules. --- backend/server.js | 2 + .../preprocessRequestDetailsMiddleware.js | 46 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 backend/src/middlewares/preprocessRequestDetailsMiddleware.js diff --git a/backend/server.js b/backend/server.js index 1fc8622..f66a249 100644 --- a/backend/server.js +++ b/backend/server.js @@ -9,6 +9,7 @@ require('dotenv').config(); // Load required modules const express = require('express'); const bodyParser = require('body-parser'); +const preprocessRequestDetailsMiddleware = require('./src/middlewares/preprocessRequestDetailsMiddleware'); const db = require('./src/db/db'); const DEFAULT_PORT = 5000; @@ -25,6 +26,7 @@ db.connect(); // Middleware setup app.use(bodyParser.json()); +app.use(preprocessRequestDetailsMiddleware); // Start the server const server = app.listen(port, host, () => { diff --git a/backend/src/middlewares/preprocessRequestDetailsMiddleware.js b/backend/src/middlewares/preprocessRequestDetailsMiddleware.js new file mode 100644 index 0000000..6191060 --- /dev/null +++ b/backend/src/middlewares/preprocessRequestDetailsMiddleware.js @@ -0,0 +1,46 @@ +/** + * @fileoverview Preprocess Request Details Middleware + * @description Middleware for preprocessing request details. + */ + +const MAX_IP_LENGTH = 45; // Maximum length for IPv6 + +/** + * @function preprocessRequestDetailsMiddleware - Middleware for preprocessing request details. + * @param {Request} req - The request object. + * @param {Response} res - The response object. + * @param {Function} next - The next function. + * @returns {Function} - The next middleware function. + */ +const preprocessRequestDetailsMiddleware = (req, res, next) => { + // Check if X-FORWARDED-FOR header is present + if (req.headers['x-forwarded-for']) { + // Get the first IP address from the X-FORWARDED-FOR header + const ip = req.headers['x-forwarded-for'].split(',')[0].trim(); + + // Limit the length of the IP address to prevent ReDoS + if (ip.length <= MAX_IP_LENGTH) { + // Maximum length for IPv6 is 45 characters + req.ip = ip; + } else { + req.ip = 'unknown'; + } + } else { + // Populate the IP address from the request object + if (req.ip && req.ip.length <= MAX_IP_LENGTH) { + // Check length before matching + req.ip = + req.ip.match(/(?:\d{1,3}\.){3}\d{1,3}|[a-fA-F0-9:]+/g)?.[0] || + 'unknown'; + } else { + req.ip = 'unknown'; + } + } + + // Populate the User-Agent from the request object + req.headers['user-agent'] = req.headers['user-agent'] || 'unknown'; + + next(); +}; + +module.exports = preprocessRequestDetailsMiddleware; From 49ef8bd091b27af23ea63812b87c98d4a05f6a2c Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Thu, 26 Dec 2024 15:32:31 -0800 Subject: [PATCH 06/24] feat(backend/middleware): Add response middleware for standardized API responses - Added `responseMiddleware` to standardize API responses in `responseMiddleware.js`. - Defined methods for sending success and various error responses with appropriate status codes and messages. - Exported the middleware for use in other modules. --- backend/server.js | 2 + backend/src/middlewares/responseMiddleware.js | 151 ++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 backend/src/middlewares/responseMiddleware.js diff --git a/backend/server.js b/backend/server.js index f66a249..8eb6307 100644 --- a/backend/server.js +++ b/backend/server.js @@ -10,6 +10,7 @@ require('dotenv').config(); const express = require('express'); const bodyParser = require('body-parser'); const preprocessRequestDetailsMiddleware = require('./src/middlewares/preprocessRequestDetailsMiddleware'); +const responseMiddleware = require('./src/middlewares/responseMiddleware'); const db = require('./src/db/db'); const DEFAULT_PORT = 5000; @@ -27,6 +28,7 @@ db.connect(); // Middleware setup app.use(bodyParser.json()); app.use(preprocessRequestDetailsMiddleware); +app.use(responseMiddleware); // Start the server const server = app.listen(port, host, () => { diff --git a/backend/src/middlewares/responseMiddleware.js b/backend/src/middlewares/responseMiddleware.js new file mode 100644 index 0000000..986aba3 --- /dev/null +++ b/backend/src/middlewares/responseMiddleware.js @@ -0,0 +1,151 @@ +/** + * @fileoverview Response Middleware + * @description Middleware for sending response. + */ + +// HTTP Status Codes +const STATUS_CODES = { + OK: 200, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + PRECONDITION_FAILED: 412, + INTERNAL_SERVER_ERROR: 500, +}; + +/** + * @function responseMiddleware - Middleware for sending response. + * @param {Request} req - The request object. + * @param {Response} res - The response object. + * @param {Function} next - The next function. + * @returns {Function} - The next middleware function. + */ +const responseMiddleware = (req, res, next) => { + /** + * @function success - Sends a success response. + * @param {Object} data - The data to send in the response. + * @param {string} message - The message to send in the response. + */ + res.success = (data, message = '') => { + res.status(STATUS_CODES.OK).json({ + status: 'success', + data: data, + message: message, + }); + }; + + /** + * @function error - Sends an error response. + * @param {string} message - The message to send in the response. + * @param {string} code - The error code. + * @param {Object} details - The error details. + * @param {number} statusCode - The status code to send in the response. + */ + res.error = ( + message, + code = 'ERROR', + details = {}, + statusCode = STATUS_CODES.INTERNAL_SERVER_ERROR, + ) => { + res.status(statusCode).json({ + status: 'error', + message: message, + error: { + code: code, + details: details, + }, + }); + }; + + /** + * @function badRequest - Sends a bad request response. + * @param {string} message - The message to send in the response. + * @param {string} code - The error code. + * @param {Object} details - The error details. + */ + res.badRequest = ( + message = 'Bad Request', + code = 'BAD_REQUEST', + details = {}, + ) => { + res.error(message, code, details, STATUS_CODES.BAD_REQUEST); + }; + + /** + * @function unauthorized - Sends an unauthorized response. + * @param {string} message - The message to send in the response. + * @param {string} code - The error code. + * @param {Object} details - The error details. + */ + res.unauthorized = ( + message = 'Unauthorized', + code = 'UNAUTHORIZED', + details = {}, + ) => { + res.error(message, code, details, STATUS_CODES.UNAUTHORIZED); + }; + + /** + * @function forbidden - Sends a forbidden response. + * @param {string} message - The message to send in the response. + * @param {string} code - The error code. + * @param {Object} details - The error details. + */ + res.forbidden = (message = 'Forbidden', code = 'FORBIDDEN', details = {}) => { + res.error(message, code, details, STATUS_CODES.FORBIDDEN); + }; + + /** + * @function notFound - Sends a not found response. + * @param {string} message - The message to send in the response. + * @param {string} code - The error code. + * @param {Object} details - The error details. + */ + res.notFound = (message = 'Not Found', code = 'NOT_FOUND', details = {}) => { + res.error(message, code, details, STATUS_CODES.NOT_FOUND); + }; + + /** + * @function conflict - Sends a conflict response. + * @param {string} message - The message to send in the response. + * @param {string} code - The error code. + * @param {Object} details - The error details. + */ + res.conflict = (message = 'Conflict', code = 'CONFLICT', details = {}) => { + res.error(message, code, details, STATUS_CODES.CONFLICT); + }; + + /** + * @function preconditionFailed - Sends a precondition failed response. + * @param {string} message - The message to send in the response. + * @param {string} code - The error code. + * @param {Object} details - The error details. + */ + res.preconditionFailed = ( + message = 'Precondition Failed', + code = 'PRECONDITION_FAILED', + details = {}, + ) => { + res.error(message, code, details, STATUS_CODES.PRECONDITION_FAILED); + }; + + /** + * @function internalServerError - Sends an internal server error response. + * @param {string} message - The message to send in the response. + * @param {string} code - The error code. + * @param {Object} details - The error details. + */ + res.internalServerError = ( + message = 'Internal Server Error', + code = 'INTERNAL_SERVER_ERROR', + details = {}, + ) => { + res.error(message, code, details, STATUS_CODES.INTERNAL_SERVER_ERROR); + }; + + next(); +}; + +module.exports = responseMiddleware; From 430d55900cfecc79faa3869f4fce39ab93cf3bfd Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Thu, 26 Dec 2024 16:01:59 -0800 Subject: [PATCH 07/24] feat(backend/services): Add JWT service for token generation, decoding, and verification - Added `generateToken` function in `jwtService.js` to generate a JWT with the specified payload, secret, and expiration time. - Added `verifyToken` function in `jwtService.js` to verify a JWT and decode its payload. - Added `decodeToken` function in `jwtService.js` to decode a JWT without verifying its signature. - Exported the `generateToken`, `verifyToken`, and `decodeToken` functions for use in other modules. --- backend/src/services/jwtService.js | 40 ++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 backend/src/services/jwtService.js diff --git a/backend/src/services/jwtService.js b/backend/src/services/jwtService.js new file mode 100644 index 0000000..5accc86 --- /dev/null +++ b/backend/src/services/jwtService.js @@ -0,0 +1,40 @@ +/** + * @fileoverview JWT Service + * @description Service for handling JWT operations. + */ + +const jwt = require('jsonwebtoken'); + +/** + * @function generateToken - Generate a JWT with the specified payload. + * @param {Object} payload - The payload to include in the JWT. + * @param {string} secret - The secret key to sign the JWT with. + * @param {string} expiresIn - The expiration time for the JWT. + * @returns {string} - The generated JWT. + */ +const generateToken = (payload, secret, expiresIn) => { + return jwt.sign(payload, secret, { expiresIn }); +}; + +/** + * @function decodeToken - Decode a JWT and return the payload. + * @param {string} token - The JWT to decode. + * @returns {Object} - The payload from the JWT. + * @throws {Error} - Throws an error if the JWT is invalid. + */ +const decodeToken = (token) => { + return jwt.decode(token); +}; + +/** + * @function verifyToken - Verify a JWT and return the payload. + * @param {string} token - The JWT to verify. + * @param {string} secret - The secret key to verify the JWT with. + * @returns {Object} - The payload from the JWT. + * @throws {Error} - Throws an error if the JWT is invalid. + */ +const verifyToken = (token, secret) => { + return jwt.verify(token, secret); +}; + +module.exports = { generateToken, decodeToken, verifyToken }; From 6289678837cdba5e6d9d5f5b234679bc26763181 Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Thu, 26 Dec 2024 16:06:30 -0800 Subject: [PATCH 08/24] feat(backend/services): Add password hash service for hashing and verifying passwords - Added `hashPassword` function in `passwordHashService.js` to hash a password using bcrypt. - Added `verifyPassword` function in `passwordHashService.js` to verify a password against a hash using bcrypt. - Exported the `hashPassword` and `verifyPassword` functions for use in other modules. --- backend/src/services/passwordHashService.js | 44 +++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 backend/src/services/passwordHashService.js diff --git a/backend/src/services/passwordHashService.js b/backend/src/services/passwordHashService.js new file mode 100644 index 0000000..405458d --- /dev/null +++ b/backend/src/services/passwordHashService.js @@ -0,0 +1,44 @@ +/** + * @fileoverview Password Hash Service + * @description Service for hashing and comparing passwords. + */ + +const bcrypt = require('bcrypt'); + +const SALT_ROUNDS = 10; // The number of salt rounds to use for hashing + +/** + * @function hashPassword - Hash a password using bcrypt. + * @param {string} password - The password to hash. + * @returns {Promise} - The hashed password. + * @throws {Error} - Throws an error if the password fails to hash. + */ +const hashPassword = async (password) => { + try { + const salt = await bcrypt.genSalt(SALT_ROUNDS); + const hashedPassword = await bcrypt.hash(password, salt); + return hashedPassword; + } catch (error) { + console.error('Error in hashing password: ', error); + throw error; + } +}; + +/** + * @function verifyPassword - Verify a password against a hash. + * @param {string} password - The password to verify. + * @param {string} hash - The hash to verify the password against. + * @returns {Promise} - A promise that resolves to true if the password is valid, false otherwise. + * @throws {Error} - Throws an error if the password verification fails. + */ +const verifyPassword = async (password, hash) => { + try { + const isValid = await bcrypt.compare(password, hash); + return isValid; + } catch (error) { + console.error('Error in verifying password: ', error); + throw error; + } +}; + +module.exports = { hashPassword, verifyPassword }; From 14cfd7f345d85986e26de61e06b48dc211edb2e6 Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Thu, 26 Dec 2024 17:04:27 -0800 Subject: [PATCH 09/24] feat(backend/middleware): Add authentication middleware for JWT verification - Added `authMiddleware` to authenticate requests using JWT in `authMiddleware.js`. - Defined public routes that do not require authentication. - Extracted and verified JWT from the `Authorization` header. - Attached the decoded user payload to the request object if the token is valid. - Handled various token errors such as expired or invalid tokens. - Exported the middleware for use in other modules. --- backend/server.js | 2 + backend/src/middlewares/authMiddleware.js | 58 +++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 backend/src/middlewares/authMiddleware.js diff --git a/backend/server.js b/backend/server.js index 8eb6307..b3a6303 100644 --- a/backend/server.js +++ b/backend/server.js @@ -11,6 +11,7 @@ const express = require('express'); const bodyParser = require('body-parser'); const preprocessRequestDetailsMiddleware = require('./src/middlewares/preprocessRequestDetailsMiddleware'); const responseMiddleware = require('./src/middlewares/responseMiddleware'); +const authMiddleware = require('./src/middlewares/authMiddleware'); const db = require('./src/db/db'); const DEFAULT_PORT = 5000; @@ -29,6 +30,7 @@ db.connect(); app.use(bodyParser.json()); app.use(preprocessRequestDetailsMiddleware); app.use(responseMiddleware); +app.use(authMiddleware); // Start the server const server = app.listen(port, host, () => { diff --git a/backend/src/middlewares/authMiddleware.js b/backend/src/middlewares/authMiddleware.js new file mode 100644 index 0000000..d6b42a4 --- /dev/null +++ b/backend/src/middlewares/authMiddleware.js @@ -0,0 +1,58 @@ +/** + * @fileoverview Authentication Middleware + * @description Middleware for authenticating requests. + */ + +const jwtService = require('../services/jwtService'); + +const PUBLIC_ROUTES = [ + '/api/auth/register', + '/api/auth/complete-registration', + '/api/auth/login', + '/api/auth/refresh-token', + '/api/auth/reset-password', + '/api/auth/complete-reset-password', +]; + +/** + * @function authMiddleware - Authenticate the user's JWT. + * @param {Request} req - The request object. + * @param {Response} res - The response object. + * @param {Function} next - The next middleware function. + * @returns {Function} - The next middleware function. + */ +const authMiddleware = (req, res, next) => { + if (PUBLIC_ROUTES.includes(req.path)) { + return next(); + } + + let token = req.headers['authorization']; + + if (!token) { + return res.unauthorized('No token provided.', 'NO_TOKEN'); + } + + if (token.startsWith('Bearer ')) { + token = token.split(' ')[1]; + } + + try { + const payload = jwtService.verifyToken(token, process.env.JWT_SECRET); + req.user = payload; + return next(); + } catch (error) { + if (error.name === 'TokenExpiredError') { + return res.unauthorized('Token expired.', 'TOKEN_EXPIRED'); + } + if (error.name === 'JsonWebTokenError') { + return res.unauthorized('Invalid token.', 'INVALID_TOKEN'); + } else { + return res.internalServerError( + 'Error verifying token.', + 'VERIFY_TOKEN_ERROR', + ); + } + } +}; + +module.exports = authMiddleware; From f28c9b02cef37d0dc5bf06472122c29b07abf125 Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Thu, 26 Dec 2024 17:26:38 -0800 Subject: [PATCH 10/24] feat(backend/services): Add email service for sending emails and verification - Added `sendEmail` function in `emailService.js` to send generic emails using nodemailer. - Added `sendEmailVerification` function in `emailService.js` to send email verification links. - Configured email details such as sender, recipient, subject, and content. - Exported the `sendEmail` and `sendEmailVerification` functions for use in other modules. --- backend/src/services/emailService.js | 69 ++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 backend/src/services/emailService.js diff --git a/backend/src/services/emailService.js b/backend/src/services/emailService.js new file mode 100644 index 0000000..9e75335 --- /dev/null +++ b/backend/src/services/emailService.js @@ -0,0 +1,69 @@ +/** + * @fileoverview Email Service + * @description Service for sending emails. + */ + +const nodemailer = require('nodemailer'); + +/** + * @function sendEmail - Send an email with the specified options.SMTP_ + * @param {Object} options - The email options. + * @param {string} options.from - The email address to send the email from. + * @param {string} options.to - The email address to send the email to. + * @param {string} options.subject - The subject of the email. + * @param {string} options.text - The text content of the email. + * @param {string} options.html - The HTML content of the email. + * @returns {Promise} - A promise that resolves when the email is sent. + * @throws {Error} - Throws an error if the email fails to send. + */ +const sendEmail = async (options) => { + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: process.env.SMTP_PORT, + secure: process.env.SMTP_SECURE === 'true', + auth: { + user: process.env.SMTP_USERNAME, + pass: process.env.SMTP_PASSWORD, + }, + }); + + const mailOptions = { + from: options.from, + to: options.to, + subject: options.subject, + text: options.text, + html: options.html, + }; + + try { + await transporter.sendMail(mailOptions); + } catch (error) { + throw error; + } +}; + +/** + * @function sendEmailVerification - Send an email verification email. (with JWT token) + * @param {string} email - The email address to send the verification email to. + * @param {string} token - The JWT token to include in the verification URL. + * @param {string} callbackUrl - The URL to redirect to after verification. + */ +const sendEmailVerification = async (email, token, callbackUrl) => { + const verificationUrl = `${callbackUrl}?token=${token}`; + + try { + await sendEmail({ + from: process.env.SMTP_SENDER, + to: email, + subject: 'Verify your email | GradeAnalyzer', + text: `Please click the following link to verify your email: ${verificationUrl}`, + html: `

Please click the following link to verify your email: ${encodeURI(verificationUrl)}

`, + }); + } catch (error) { + throw error; + } +}; + +module.exports = { sendEmail, sendEmailVerification }; From 013b594de333cafbd99e36188c0c5b1a2f27e7b4 Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Fri, 27 Dec 2024 12:40:31 -0800 Subject: [PATCH 11/24] feat(backend/models): Add user schema and model for MongoDB - Defined `settingsSchema` to store user settings such as time format and theme. - Defined `safetyRecordSchema` to store safety records like login attempts and password changes. - Defined `userSchema` to store user information including username, email, password, roles, and more. - Indexed `username` and `email` fields for uniqueness. - Excluded `password`, `settings`, and `safetyRecords` fields from JSON responses by default. - Created and exported the `User` model for use in other modules. --- backend/src/models/userSchema.js | 148 +++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 backend/src/models/userSchema.js diff --git a/backend/src/models/userSchema.js b/backend/src/models/userSchema.js new file mode 100644 index 0000000..389fd17 --- /dev/null +++ b/backend/src/models/userSchema.js @@ -0,0 +1,148 @@ +/** + * @fileoverview User Schema + * @description User schema for the MongoDB database. + */ + +const mongoose = require('mongoose'); + +// Define the settings schema +const settingsSchema = new mongoose.Schema({ + timeFormat: { + type: String, + enum: ['12h', '24h'], + required: true, + default: '12h', + }, + theme: { + type: String, + enum: ['light', 'dark', 'system'], + required: true, + default: 'system', + }, +}); + +// Define the safety record schema +const safetyRecordSchema = new mongoose.Schema( + { + type: { + type: String, + enum: [ + 'LOGIN_SUCCESS', + 'LOGIN_FAILED', + 'PASSWORD_RESET_REQUESTED', + 'PASSWORD_RESET_SUCCESS', + 'EMAIL_CHANGED', + 'PASSWORD_CHANGED', + 'ACCOUNT_CREATED', + 'ACCOUNT_LOCKED', + 'ACCOUNT_UNLOCKED', + ], + required: true, + }, + date: { + type: Date, + required: true, + }, + ip: { + type: String, + required: true, + }, + device: { + type: String, + required: true, + }, + }, + { + _id: false, + }, +); + +// Define the user schema +const userSchema = new mongoose.Schema( + { + username: { + type: String, + required: true, + unique: true, + trim: true, + lowercase: true, + }, + displayName: { + type: String, + required: true, + default: function defaultDisplayName() { + return this.username; + }, + }, + email: { + type: String, + required: true, + unique: true, + trim: true, + lowercase: true, + }, + password: { + type: String, + required: true, + }, + avatar: { + type: String, + required: false, + default: '', + }, + school: { + type: String, + required: false, + trim: true, + }, + country: { + type: String, + required: false, + trim: true, + }, + roles: { + type: [String], + required: true, + default: ['USER'], + }, + locked: { + type: Boolean, + required: true, + default: false, + }, + settings: { + type: settingsSchema, + required: true, + default: {}, + select: false, // Do not return the settings by default + }, + safetyRecords: { + type: [safetyRecordSchema], + required: false, + default: [], + select: false, // Do not return the safety records by default + }, + }, + { + timestamps: true, + }, +); + +// Index the username field +userSchema.index({ username: 1 }, { unique: true }); + +// Index the email field +userSchema.index({ email: 1 }, { unique: true }); + +// Delete the password field when converting to JSON +userSchema.set('toJSON', { + transform: (doc, ret) => { + delete ret.password; + return ret; + }, +}); + +// Create the user model +const User = mongoose.model('User', userSchema); + +module.exports = User; From de7d52998523637e17f6eea93275f75b54d48ae1 Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Fri, 27 Dec 2024 13:22:22 -0800 Subject: [PATCH 12/24] feat(backend/services): Add user service for database interactions - Added `createUser` function to create a new user with hashed password. - Added `loginUser` function to authenticate a user by email/username and password. - Added `getRefreshTokenSecret` function to generate a refresh token secret using user ID and secret key. - Added `getUserById`, `getUserByEmail`, and `getUserByUsername` functions to retrieve user details. - Added `updateUserById` function to update user details by ID. - Added `getSettingsById` and `updateSettingsById` functions to manage user settings. - Added `addSafetyRecordById` function to add safety records to a user. - Added `getSafetyRecordsById` function to retrieve user safety records with pagination. - Exported all functions for use in other modules. --- backend/src/services/userService.js | 283 ++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 backend/src/services/userService.js diff --git a/backend/src/services/userService.js b/backend/src/services/userService.js new file mode 100644 index 0000000..d532bcc --- /dev/null +++ b/backend/src/services/userService.js @@ -0,0 +1,283 @@ +/** + * @fileoverview User Service + * @description User service for interacting with the database. + */ + +const mongoose = require('mongoose'); +const User = require('../models/userSchema'); +const { hashPassword, verifyPassword } = require('./passwordHashService'); + +const DEFAULT_SAFETY_RECORDS_LIMIT = 20; // The default number of safety records to return + +/** + * @function createUser - Create a new user. + * @param {Object} user - The user object to create. + * @returns {Promise} - The created user object. + * @throws {Error} - Throws an error if the user fails to create. + */ +const createUser = async (user) => { + try { + if (user.password) { + user.password = await hashPassword(user.password); + } else { + throw new Error('Password is required'); + } + const newUser = new User(user); + const savedUser = await newUser.save(); + return savedUser; + } catch (error) { + console.error('Error in creating user: ', error); + throw error; + } +}; + +/** + * @function loginUser - Log in a user. + * @param {string} identifier - The user's email or username. + * @param {string} password - The user's password. + * @returns {Promise} - The logged in user object. + * @throws {Error} - Throws an error if the login fails. + */ +const loginUser = async (identifier, password) => { + try { + const user = await User.findOne({ + $or: [{ email: identifier }, { username: identifier }], + }); + if (!user) { + throw { message: 'User not found', user: null }; + } + const isValid = await verifyPassword(password, user.password); + if (!isValid) { + throw { message: 'Invalid password', user: user }; + } + if (user.locked) { + throw { message: 'Account locked', user: user }; + } + return user; + } catch (error) { + throw error; + } +}; + +/** + * @function getRefreshTokenSecret - Get the refresh token secret for a user. (Secret key + user Password) + * @param {string} userId - The user's ID. + * @param {string} secret - The secret key. + * @returns {string} - The refresh token secret. + * @throws {Error} - Throws an error if the secret fails to get. + */ +const getRefreshTokenSecret = async (userId, secret) => { + if (!mongoose.Types.ObjectId.isValid(userId)) { + throw new Error('Invalid user ID'); + } + + try { + const user = await User.findById(userId); + if (!user) throw new Error('User not found'); + return `${secret}.${user.password}.${user.locked}`; + } catch (error) { + throw error; + } +}; + +/** + * @function getUserById - Get a user by ID. + * @param {string} userId - The user's ID. + * @returns {Promise} - The user object. + * @throws {Error} - Throws an error if the user fails to get. + */ +const getUserById = async (userId) => { + if (!mongoose.Types.ObjectId.isValid(userId)) return null; + + try { + const user = await User.findById(userId); + return user; + } catch (error) { + console.error('Error in getting user by ID: ', error); + throw error; + } +}; + +/** + * @function getUserByEmail - Get a user by email. + * @param {string} email - The user's email. + * @returns {Promise} - The user object. + * @throws {Error} - Throws an error if the user fails to get. + */ +const getUserByEmail = async (email) => { + try { + const user = await User.findOne({ email }); + return user; + } catch (error) { + console.error('Error in getting user by email: ', error); + throw error; + } +}; + +/** + * @function getUserByUsername - Get a user by username. + * @param {string} username - The user's username. + * @returns {Promise} - The user object. + * @throws {Error} - Throws an error if the user fails to get. + */ +const getUserByUsername = async (username) => { + try { + const user = await User.findOne({ username }); + return user; + } catch (error) { + console.error('Error in getting user by username: ', error); + throw error; + } +}; + +/** + * @function updateUserById - Update a user by ID. + * @param {string} userId - The user's ID. + * @param {Object} update - The user object to update. + * @returns {Promise} - The updated user object. + * @throws {Error} - Throws an error if the user fails to update. + */ +const updateUserById = async (userId, update) => { + if (!mongoose.Types.ObjectId.isValid(userId)) return null; + + try { + if (update.password) { + update.password = await hashPassword(update.password); + } + const updatedUser = await User.findByIdAndUpdate(userId, update, { + new: true, + }); + if (!updatedUser) { + throw new Error('User not found'); + } + return updatedUser; + } catch (error) { + console.error('Error in updating user by ID: ', error); + throw error; + } +}; + +/** + * @function getSettingsById - Get the settings of a user by ID. + * @param {string} userId - The user's ID. + * @returns {Promise} - The user's settings. + * @throws {Error} - Throws an error if the user fails to get. + */ +const getSettingsById = async (userId) => { + if (!mongoose.Types.ObjectId.isValid(userId)) return null; + + try { + const user = await User.findById(userId, { settings: 1 }); + if (!user) throw new Error('User not found'); + return user.settings; + } catch (error) { + console.error('Error in getting settings by ID: ', error); + throw error; + } +}; + +/** + * @function updateSettingsById - Update the settings of a user by ID. + * @param {string} userId - The user's ID. + * @param {Object} settings - The user's settings. + * @returns {Promise} - The updated user object. + * @throws {Error} - Throws an error if the user fails to update. + */ +const updateSettingsById = async (userId, settings) => { + if (!mongoose.Types.ObjectId.isValid(userId)) return null; + + try { + const updatedUser = await User.findByIdAndUpdate( + userId, + { settings }, + { new: true }, + ); + if (!updatedUser) throw new Error('User not found'); + return updatedUser; + } catch (error) { + console.error('Error in updating settings by ID: ', error); + throw error; + } +}; + +/** + * @function addSafetyRecordById - Add a safety record to a user by ID. The safety records are limited to 100 records. + * @param {string} userId - The user's ID. + * @param {string} type - The type of safety record. + * @param {string} ip - The IP address of the safety record. + * @param {string} device - The device of the safety record. + * @returns {Promise} - The updated user object. + * @throws {Error} - Throws an error if the user fails to update. + */ +const addSafetyRecordById = async (userId, type, ip, device) => { + if (!mongoose.Types.ObjectId.isValid(userId)) { + throw new Error('Invalid user ID'); + } + + try { + const safetyRecord = { type, date: new Date(), ip, device }; + const updatedUser = await User.findByIdAndUpdate( + userId, + { + $push: { + safetyRecords: { + $each: [safetyRecord], + $sort: { date: -1 }, + $slice: -100, + }, + }, + }, + { new: true }, + ); + + if (!updatedUser) throw new Error('User not found'); + + return updatedUser; + } catch (error) { + console.error('Error in adding safety record by ID: ', error); + throw error; + } +}; + +/** + * @function getSafetyRecordsById - Get the safety records of a user by ID. + * @param {string} userId - The user's ID. + * @param {number} limit - The number of safety records to return. + * @param {number} offset - The number of safety records to skip. + * @returns {Promise} - The user's safety records. + * @throws {Error} - Throws an error if the user fails to get. + */ +const getSafetyRecordsById = async ( + userId, + limit = DEFAULT_SAFETY_RECORDS_LIMIT, + offset = 0, +) => { + if (!mongoose.Types.ObjectId.isValid(userId)) return null; + + try { + const user = await User.findById(userId, { + safetyRecords: { $slice: [offset, limit] }, + }); + + if (!user) throw new Error('User not found or no safety records'); + + return user.safetyRecords; + } catch (error) { + console.error('Error in getting safety records by ID: ', error); + throw error; + } +}; + +module.exports = { + createUser, + loginUser, + getRefreshTokenSecret, + getUserById, + getUserByEmail, + getUserByUsername, + updateUserById, + getSettingsById, + updateSettingsById, + addSafetyRecordById, + getSafetyRecordsById, +}; From 0291cecfe943c30317d35f3786709a8412a9917e Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Fri, 27 Dec 2024 14:05:01 -0800 Subject: [PATCH 13/24] feat(backend/models): Add dateFormat field to user schema with default value --- backend/src/models/userSchema.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/src/models/userSchema.js b/backend/src/models/userSchema.js index 389fd17..b97a287 100644 --- a/backend/src/models/userSchema.js +++ b/backend/src/models/userSchema.js @@ -13,6 +13,12 @@ const settingsSchema = new mongoose.Schema({ required: true, default: '12h', }, + dateFormat: { + type: String, + enum: ['MM-DD-YYYY', 'DD-MM-YYYY', 'YYYY-MM-DD'], + required: true, + default: 'MM-DD-YYYY', + }, theme: { type: String, enum: ['light', 'dark', 'system'], From 21387e73beed71457a0f39057ee8a1a8d0df3bb4 Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Fri, 27 Dec 2024 14:11:40 -0800 Subject: [PATCH 14/24] feat(backend/utils): Add validation utilities for user input - Added `validateEmail` function to validate email addresses. - Added `validatePassword` function to validate passwords. - Added `validateUsername` function to validate usernames. - Added `validateDisplayName` function to validate display names. - Added `validateSchool` function to validate school names. - Added `validateCountry` function to validate country codes. - Added `validateTimeFormat` function to validate time formats. - Added `validateDateFormat` function to validate date formats. - Added `validateTheme` function to validate themes. - Exported all validation functions for use in other modules. --- backend/src/utils/validationUtils.js | 166 +++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 backend/src/utils/validationUtils.js diff --git a/backend/src/utils/validationUtils.js b/backend/src/utils/validationUtils.js new file mode 100644 index 0000000..5cc6081 --- /dev/null +++ b/backend/src/utils/validationUtils.js @@ -0,0 +1,166 @@ +/** + * @fileoverview Validation Utils + * @description Utility functions for validation. + */ + +const MAX_EMAIL_LENGTH = 254; // The maximum length of an email address + +const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; // Check if the email is valid (contains an @ symbol and a period) +const PASSWORD_REGEX = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\W_])[^\s]{8,}$/; // Check if the password is valid (at least 8 characters, one uppercase letter, one lowercase letter, one number, and one special character) +const USERNAME_REGEX = /^[a-zA-Z0-9_]{3,}$/; // Check if the username is valid (at least 3 characters, alphanumeric characters and underscores only) +const DISPLAY_NAME_REGEX = /^(?!.*\s{2,})[^\s](.{0,18}[^\s])?$|^$/; // Check if the display name is valid (no more than 20 characters, no leading or trailing spaces, no consecutive spaces) +const SCHOOL_REGEX = /^[a-zA-Z0-9\s]{3,}$/; // Check if the school name is valid (at least 3 characters, alphanumeric characters and spaces only) +const COUNTRY_REGEX = /^[a-zA-Z]{2}$/; // Check if the country code is valid (2 letter country code) +const TIME_FORMAT_REGEX = /^(12h|24h)$/; // Check if the time format is valid (12h or 24h) +const DATE_FORMAT_REGEX = /^(MM-DD-YYYY|DD-MM-YYYY|YYYY-MM-DD)$/; // Check if the date format is valid (MM-DD-YYYY, DD-MM-YYYY, or YYYY-MM-DD) +const THEME_REGEX = /^(light|dark|system)$/; // Check if the theme is valid (light, dark, or system) + +/** + * @function validateEmail - Validate an email address. + * @param {string} email - The email address to validate. + * @returns {boolean} - True if the email is valid, false otherwise. + */ +const validateEmail = (email) => { + if (!email) { + // Check if the email is empty + return false; + } else if (email.length > MAX_EMAIL_LENGTH) { + // Check if the email is too long + return false; + } else { + // Check if the email matches the regex pattern + return EMAIL_REGEX.test(email); + } +}; + +/** + * @function validatePassword - Validate a password. + * @param {string} password - The password to validate. + * @returns {boolean} - True if the password is valid, false otherwise. + */ +const validatePassword = (password) => { + if (!password) { + // Check if the password is empty + return false; + } else { + // Check if the password matches the regex pattern + return PASSWORD_REGEX.test(password); + } +}; + +/** + * @function validateUsername - Validate a username. + * @param {string} username - The username to validate. + * @returns {boolean} - True if the username is valid, false otherwise. + */ +const validateUsername = (username) => { + if (!username) { + // Check if the username is empty + return false; + } else { + // Check if the username matches the regex pattern + return USERNAME_REGEX.test(username); + } +}; + +/** + * @function validateDisplayName - Validate a display name. + * @param {string} displayName - The display name to validate. + * @returns {boolean} - True if the display name is valid, false otherwise. + */ +const validateDisplayName = (displayName) => { + if (displayName === undefined || displayName === null) { + // Check if the display name is empty (null or undefined, but not an empty string) + return false; + } else { + // Check if the display name matches the regex pattern + return DISPLAY_NAME_REGEX.test(displayName); + } +}; + +/** + * @function validateSchool - Validate a school name. + * @param {string} school - The school name to validate. + * @returns {boolean} - True if the school name is valid, false otherwise. + */ +const validateSchool = (school) => { + if (school === undefined || school === null) { + // Check if the school name is empty (null or undefined, but not an empty string) + return false; + } else { + // Check if the school name matches the regex pattern + return SCHOOL_REGEX.test(school); + } +}; + +/** + * @function validateCountry - Validate a country code. + * @param {string} country - The country code to validate. + * @returns {boolean} - True if the country code is valid, false otherwise. + */ +const validateCountry = (country) => { + if (country === undefined || country === null) { + // Check if the country code is empty (null or undefined, but not an empty string) + return false; + } else { + // Check if the country code matches the regex pattern + return COUNTRY_REGEX.test(country); + } +}; + +/** + * @function validateTimeFormat - Validate a time format. + * @param {string} timeFormat - The time format to validate. + * @returns {boolean} - True if the time format is valid, false otherwise. + */ +const validateTimeFormat = (timeFormat) => { + if (timeFormat === undefined || timeFormat === null) { + // Check if the time format is empty (null or undefined, but not an empty string) + return false; + } else { + // Check if the time format matches the regex pattern + return TIME_FORMAT_REGEX.test(timeFormat); + } +}; + +/** + * @function validateDateFormat - Validate a date format. + * @param {string} dateFormat - The date format to validate. + * @returns {boolean} - True if the date format is valid, false otherwise. + */ +const validateDateFormat = (dateFormat) => { + if (dateFormat === undefined || dateFormat === null) { + // Check if the date format is empty (null or undefined, but not an empty string) + return false; + } else { + // Check if the date format matches the regex pattern + return DATE_FORMAT_REGEX.test(dateFormat); + } +}; + +/** + * @function validateTheme - Validate a theme. + * @param {string} theme - The theme to validate. + * @returns {boolean} - True if the theme is valid, false otherwise. + */ +const validateTheme = (theme) => { + if (theme === undefined || theme === null) { + // Check if the theme is empty (null or undefined, but not an empty string) + return false; + } else { + // Check if the theme matches the regex pattern + return THEME_REGEX.test(theme); + } +}; + +module.exports = { + validateEmail, + validatePassword, + validateUsername, + validateDisplayName, + validateSchool, + validateCountry, + validateTimeFormat, + validateDateFormat, + validateTheme, +}; From 40625f11a61a3437e63afe71998263dfe0a5eaab Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Fri, 27 Dec 2024 17:01:28 -0800 Subject: [PATCH 15/24] feat(backend/utils): Add rate limiter middleware to control request frequency - Added `rateLimiter` function in `rateLimiter.js` to create a rate limiter middleware using `express-rate-limit`. - Configured default settings for maximum requests and time window. - Provided a custom handler to respond with a 429 status code and error message when the rate limit is exceeded. - Bypassed rate limiting in test environment. - Exported the `rateLimiter` function for use in other modules. --- backend/src/utils/rateLimiter.js | 47 ++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 backend/src/utils/rateLimiter.js diff --git a/backend/src/utils/rateLimiter.js b/backend/src/utils/rateLimiter.js new file mode 100644 index 0000000..6a5fa74 --- /dev/null +++ b/backend/src/utils/rateLimiter.js @@ -0,0 +1,47 @@ +/** + * @fileoverview Rate Limiter + * @description Rate limiter middleware to limit the number of requests per IP. + */ + +const rateLimit = require('express-rate-limit'); + +const DEFAULT_MAX_REQUESTS = 150; +const DEFAULT_TIME_WINDOW = 300000; // 5 minutes (5 * 60 * 1000) +const TOO_MANY_REQUESTS_STATUS_CODE = 429; + +/** + * @function rateLimiter - Create a rate limiter middleware. + * @param {number} max - The maximum number of requests. + * @param {number} window - The time window in milliseconds. + * @returns {Function} - A rate limiter middleware. + */ +const rateLimiter = ( + max = DEFAULT_MAX_REQUESTS, + window = DEFAULT_TIME_WINDOW, +) => { + if (process.env.NODE_ENV === 'test') { + return (req, res, next) => { + next(); + }; + } + + return rateLimit({ + windowMs: window, + max, + handler: (req, res) => { + res.status(TOO_MANY_REQUESTS_STATUS_CODE).json({ + status: 'error', + message: 'Too many requests, please try again later.', + error: { + code: 'TOO_MANY_REQUESTS', + details: { + max, + window, + }, + }, + }); + }, + }); +}; + +module.exports = rateLimiter; From ef4e348c4f54388db5024306defe87030c084d49 Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Sat, 28 Dec 2024 06:26:08 -0800 Subject: [PATCH 16/24] feat(backend/auth): Add user authentication routes and controllers - Added `authRoutes.js` to define routes for user authentication, including registration with rate limiting. - Added `authController.js` to handle user registration requests. - Implemented `registerUser` function to validate email, generate a verification token, and send a verification email. - Added `authMiddleware.js` to authenticate requests using JWT, allowing public access to specific routes. - Configured rate limiting for registration and password/email reset requests. - Exported necessary modules for use in other parts of the application. --- README.md | 4 + backend/docs/API.md | 92 +++++++++++++++++++++ backend/server.js | 4 + backend/src/controllers/authController.js | 48 +++++++++++ backend/src/middlewares/authMiddleware.js | 2 +- backend/src/routes/authRoutes.js | 22 +++++ backend/src/routes/index.js | 17 ++++ backend/src/services/passwordHashService.js | 2 +- 8 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 backend/docs/API.md create mode 100644 backend/src/controllers/authController.js create mode 100644 backend/src/routes/authRoutes.js create mode 100644 backend/src/routes/index.js diff --git a/README.md b/README.md index fe142a9..90d5f2b 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,10 @@ GradeAnalyzer is a powerful and user-friendly tool designed to help students tra Open the browser and navigate to the server address to `http://localhost` to access the GradeAnalyzer application. (Use the configured Nginx server address if applicable) +## Usage + +The GradeAnalyzer application provides a RESTful API to interact with the GradeAnalyzer backend. The API endpoints are documented in the [API.md](backend/docs/API.md) file. + ## License This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details. diff --git a/backend/docs/API.md b/backend/docs/API.md new file mode 100644 index 0000000..fd2b97e --- /dev/null +++ b/backend/docs/API.md @@ -0,0 +1,92 @@ +# API Documentation + +The GradeAnalyzer application provides a RESTful API to interact with the GradeAnalyzer backend. The API is secured with JWT (JSON Web Token) authentication and includes rate limiting to prevent abuse. + +## Authentication + +The API uses JWT (JSON Web Token) for authentication. To access protected routes, the client must include a valid JWT token in the `Authorization` header of the HTTP request. (Access tokens are valid for 15 minutes, and refresh tokens are valid for 30 days.) + +## Rate Limiting + +The API has specific rate limits for different functionalities to ensure fair usage and performance. The limits are as follows: + +1. **Registration and Password/Email Reset**: + + - **Rate Limit**: 3 requests every 2 minutes per IP address. + - **Description**: This functionality involves sending verification emails. Users are allowed up to 3 registration or password reset requests every 2 minutes. + +2. **Login**: + + - **Rate Limit**: 15 requests every hour per IP address. + - **Description**: Users can attempt to log in up to 15 times within a 1-hour period. + +3. **Other API Features**: + - **Rate Limit**: 150 requests every 5 minutes per IP address. + - **Description**: For all other API functionalities not explicitly listed, users can send up to 150 requests every 5 minutes. + +### Response to Rate Limit Exceedance + +If the rate limit is exceeded, the server will respond with a `429 Too Many Requests` status code, indicating that the user has made too many requests in a given timeframe. Users should manage their request rates accordingly to avoid interruptions in service. + +## Endpoints + +### Table of Contents + +- [Authentication Endpoints](#authentication-endpoints) + - [User Registration](#user-registration) + +### Authentication Endpoints + +#### User Registration + +- **URL:** `/api/auth/register` +- **Method:** `POST` + +- **Request Body**: + + ```json + { + "email": "user@example.com", + "callbackUrl": "https://example.com/verify-email" + } + ``` + + > **Note:** The `callbackUrl` is the URL will be the url sent to the user in the email for email verification. + +- **Response**: + + - **Status:** `200 OK` + + ```json + { + "status": "success", + "data": null, + "message": "Verification email sent." + } + ``` + + - **Status:** `400 Bad Request` + + ```json + { + "status": "error", + "message": "Invalid email address.", + "error": { + "code": "INVALID_EMAIL", + "details": {} + } + } + ``` + + - **Status:** `500 Internal Server Error` + + ```json + { + "status": "error", + "message": "Error sending verification email.", + "error": { + "code": "SEND_EMAIL_ERROR", + "details": {} + } + } + ``` diff --git a/backend/server.js b/backend/server.js index b3a6303..356063e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -13,6 +13,7 @@ const preprocessRequestDetailsMiddleware = require('./src/middlewares/preprocess const responseMiddleware = require('./src/middlewares/responseMiddleware'); const authMiddleware = require('./src/middlewares/authMiddleware'); const db = require('./src/db/db'); +const routes = require('./src/routes'); const DEFAULT_PORT = 5000; const port = process.env.PORT || DEFAULT_PORT; @@ -32,6 +33,9 @@ app.use(preprocessRequestDetailsMiddleware); app.use(responseMiddleware); app.use(authMiddleware); +// Routes setup +app.use('/api', routes); + // Start the server const server = app.listen(port, host, () => { console.log(`Backend server is running on http://${host}:${port}`); diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js new file mode 100644 index 0000000..c6a6104 --- /dev/null +++ b/backend/src/controllers/authController.js @@ -0,0 +1,48 @@ +/** + * @fileoverview Auth Controller + * @description Controllers for user authentication. + */ + +const userService = require('../services/userService'); +const emailService = require('../services/emailService'); +const jwtService = require('../services/jwtService'); + +const validationUtils = require('../utils/validationUtils'); + +/** + * @function registerUser - Handle user registration request. + * @param {Request} req - The request object. + * @param {Response} res - The response object. + */ +const registerUser = async (req, res) => { + const { email, callbackUrl } = req.body; + + // Check if the email is valid + if (!email || !validationUtils.validateEmail(email)) { + return res.badRequest('Invalid email address.', 'INVALID_EMAIL'); + } + + // Generate a JWT token with the email (for verification) + const token = jwtService.generateToken( + { email }, + process.env.JWT_SECRET, + '1h', + ); + + // Send the verification email + try { + await emailService.sendEmailVerification(email, token, callbackUrl); + + return res.success(null, 'Verification email sent.'); + } catch (error) { + console.error('Error sending verification email: ', error); + return res.internalServerError( + 'Error sending verification email.', + 'SEND_EMAIL_ERROR', + ); + } +}; + +module.exports = { + registerUser, +}; diff --git a/backend/src/middlewares/authMiddleware.js b/backend/src/middlewares/authMiddleware.js index d6b42a4..df68687 100644 --- a/backend/src/middlewares/authMiddleware.js +++ b/backend/src/middlewares/authMiddleware.js @@ -1,6 +1,6 @@ /** * @fileoverview Authentication Middleware - * @description Middleware for authenticating requests. + * @description Middleware for user authentication. */ const jwtService = require('../services/jwtService'); diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js new file mode 100644 index 0000000..516dca6 --- /dev/null +++ b/backend/src/routes/authRoutes.js @@ -0,0 +1,22 @@ +/** + * @fileoverview Auth Routes + * @description Routes for user authentication. + */ + +const express = require('express'); +const router = express.Router(); + +const rateLimiter = require('../utils/rateLimiter'); + +const authController = require('../controllers/authController'); + +const RATE_LIMIT_MAX_3 = 3; +const RATE_LIMIT_WINDOW_2_MINUTES = 120000; // 2 minutes (2 * 60 * 1000) + +router.post( + '/register', + rateLimiter(RATE_LIMIT_MAX_3, RATE_LIMIT_WINDOW_2_MINUTES), + authController.registerUser, +); + +module.exports = router; diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js new file mode 100644 index 0000000..05caf19 --- /dev/null +++ b/backend/src/routes/index.js @@ -0,0 +1,17 @@ +/** + * @fileoverview Routes Index + * @description Index file for routes. + */ + +const express = require('express'); +const router = express.Router(); + +const authRoutes = require('./authRoutes'); + +router.use('/auth', authRoutes); + +router.get('*', (req, res) => { + res.notFound(); +}); + +module.exports = router; diff --git a/backend/src/services/passwordHashService.js b/backend/src/services/passwordHashService.js index 405458d..b07c34a 100644 --- a/backend/src/services/passwordHashService.js +++ b/backend/src/services/passwordHashService.js @@ -3,7 +3,7 @@ * @description Service for hashing and comparing passwords. */ -const bcrypt = require('bcrypt'); +const bcrypt = require('bcryptjs'); const SALT_ROUNDS = 10; // The number of salt rounds to use for hashing From e5768676194dfa20a4b651f447f65494708fd509 Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Mon, 30 Dec 2024 17:12:53 -0800 Subject: [PATCH 17/24] feat(backend/auth): Add complete registration endpoint and update API documentation - Added `completeRegistration` endpoint in `authRoutes.js` to handle user registration completion. - Implemented `completeRegistration` controller in `authController.js` to verify token, validate username and password, and create a new user. - Updated `API.md` to include documentation for the `completeRegistration` endpoint. - Included request body and response examples for the new endpoint. --- backend/docs/API.md | 145 +++++++++++++++++++++- backend/src/controllers/authController.js | 74 ++++++++++- backend/src/routes/authRoutes.js | 5 + 3 files changed, 221 insertions(+), 3 deletions(-) diff --git a/backend/docs/API.md b/backend/docs/API.md index fd2b97e..d7fc824 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -21,6 +21,7 @@ The API has specific rate limits for different functionalities to ensure fair us - **Description**: Users can attempt to log in up to 15 times within a 1-hour period. 3. **Other API Features**: + - **Rate Limit**: 150 requests every 5 minutes per IP address. - **Description**: For all other API functionalities not explicitly listed, users can send up to 150 requests every 5 minutes. @@ -32,8 +33,15 @@ If the rate limit is exceeded, the server will respond with a `429 Too Many Requ ### Table of Contents -- [Authentication Endpoints](#authentication-endpoints) - - [User Registration](#user-registration) +- [API Documentation](#api-documentation) + - [Authentication](#authentication) + - [Rate Limiting](#rate-limiting) + - [Response to Rate Limit Exceedance](#response-to-rate-limit-exceedance) + - [Endpoints](#endpoints) + - [Table of Contents](#table-of-contents) + - [Authentication Endpoints](#authentication-endpoints) + - [User Registration](#user-registration) + - [Complete Registration](#complete-registration) ### Authentication Endpoints @@ -90,3 +98,136 @@ If the rate limit is exceeded, the server will respond with a `429 Too Many Requ } } ``` + +#### Complete Registration + +- **URL:** `/api/auth/complete-registration` +- **Method:** `POST` + +- **Request Body**: + + ```json + { + "token": "JWT_TOKEN (received in email)", + "username": "username", + "password": "password" + } + ``` + +- **Response**: + + - **Status:** `200 OK` + + ```json + { + "status": "success", + "data": {}, + "message": "User created successfully." + } + ``` + + > **Note:** The user data will be returned in the response (`data` field). + + - **Status:** `400 Bad Request` + + ```json + { + "status": "error", + "message": "Invalid username.", + "error": { + "code": "INVALID_USERNAME", + "details": {} + } + } + ``` + + - **Status:** `400 Bad Request` + + ```json + { + "status": "error", + "message": "Invalid password.", + "error": { + "code": "INVALID_PASSWORD", + "details": {} + } + } + ``` + + - **Status:** `400 Bad Request` + + ```json + { + "status": "error", + "message": "Invalid token.", + "error": { + "code": "INVALID_TOKEN", + "details": {} + } + } + ``` + + - **Status:** `401 Unauthorized` + + ```json + { + "status": "error", + "message": "Token expired.", + "error": { + "code": "TOKEN_EXPIRED", + "details": {} + } + } + ``` + + - **Status:** `500 Internal Server Error` + + ```json + { + "status": "error", + "message": "Error verifying token.", + "error": { + "code": "VERIFY_TOKEN_ERROR", + "details": {} + } + } + ``` + + - **Status:** `409 Conflict` + + ```json + { + "status": "error", + "message": "Email already in use.", + "error": { + "code": "EMAIL_IN_USE", + "details": {} + } + } + ``` + + - **Status:** `409 Conflict` + + ```json + { + "status": "error", + "message": "Username already in use.", + "error": { + "code": "USERNAME_IN_USE", + "details": {} + } + } + ``` + + - **Status:** `500 Internal Server Error` + + ```json + { + "status": "error", + "message": "Error creating user.", + "error": { + "code": "CREATE_USER_ERROR", + "details": {} + } + } + ``` diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js index c6a6104..03af420 100644 --- a/backend/src/controllers/authController.js +++ b/backend/src/controllers/authController.js @@ -1,5 +1,5 @@ /** - * @fileoverview Auth Controller + * @fileoverview Auth Controllers * @description Controllers for user authentication. */ @@ -43,6 +43,78 @@ const registerUser = async (req, res) => { } }; +/** + * @function completeRegistration - Handle user registration completion request. + * @param {Request} req - The request object. + * @param {Response} res - The response object. + */ +const completeRegistration = async (req, res) => { + const { token, username, password } = req.body; + + let email; + + // Verify JWT token + try { + const payload = jwtService.verifyToken(token, process.env.JWT_SECRET); + if (!payload.email) { + return res.badRequest('Invalid token.', 'INVALID_TOKEN'); + } + email = payload.email; + } catch (error) { + if (error.name === 'TokenExpiredError') { + return res.unauthorized('Token expired.', 'TOKEN_EXPIRED'); + } + if (error.name === 'JsonWebTokenError') { + return res.badRequest('Invalid token.', 'INVALID_TOKEN'); + } else { + return res.internalServerError( + 'Error verifying token.', + 'VERIFY_TOKEN_ERROR', + ); + } + } + + // Check if the username is valid + if (!username || !validationUtils.validateUsername(username)) { + return res.badRequest('Invalid username.', 'INVALID_USERNAME'); + } + + // Check if the password is valid + if (!password || !validationUtils.validatePassword(password)) { + return res.badRequest('Invalid password.', 'INVALID_PASSWORD'); + } + + // Check if the email is already in use + const existingEmail = await userService.getUserByEmail(email); + if (existingEmail) { + return res.conflict('Email already in use.', 'EMAIL_IN_USE'); + } + + // Check if the username is already in use + const existingUsername = await userService.getUserByUsername(username); + if (existingUsername) { + return res.conflict('Username already in use.', 'USERNAME_IN_USE'); + } + + // Create the user + try { + const user = await userService.createUser({ email, username, password }); + + // Log the user's account creation + userService.addSafetyRecordById( + user._id, + 'ACCOUNT_CREATED', + req.ip, + req.headers['user-agent'], + ); + + return res.success(user, 'User created successfully.'); + } catch (error) { + return res.internalServerError('Error creating user.', 'CREATE_USER_ERROR'); + } +}; + module.exports = { registerUser, + completeRegistration, }; diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js index 516dca6..ccbd3b6 100644 --- a/backend/src/routes/authRoutes.js +++ b/backend/src/routes/authRoutes.js @@ -18,5 +18,10 @@ router.post( rateLimiter(RATE_LIMIT_MAX_3, RATE_LIMIT_WINDOW_2_MINUTES), authController.registerUser, ); +router.post( + '/complete-registration', + rateLimiter(), + authController.completeRegistration, +); module.exports = router; From 84a491c3787bc43997b68f7e9f49e578e6649794 Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Mon, 30 Dec 2024 18:21:26 -0800 Subject: [PATCH 18/24] feat(backend/auth): Implement user login endpoint and update API documentation - Added `loginUser` endpoint in `authRoutes.js` to handle user login requests. - Implemented `loginUser` controller in `authController.js` to validate credentials, authenticate user, and generate JWT tokens. - Updated `API.md` to include documentation for the `loginUser` endpoint. - Included request body and response examples for the new endpoint. --- backend/docs/API.md | 98 +++++++++++++++++++ .../{authController.js => authControllers.js} | 76 ++++++++++++++ backend/src/routes/authRoutes.js | 14 ++- 3 files changed, 185 insertions(+), 3 deletions(-) rename backend/src/controllers/{authController.js => authControllers.js} (63%) diff --git a/backend/docs/API.md b/backend/docs/API.md index d7fc824..9e0200e 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -42,6 +42,7 @@ If the rate limit is exceeded, the server will respond with a `429 Too Many Requ - [Authentication Endpoints](#authentication-endpoints) - [User Registration](#user-registration) - [Complete Registration](#complete-registration) + - [User Login](#user-login) ### Authentication Endpoints @@ -231,3 +232,100 @@ If the rate limit is exceeded, the server will respond with a `429 Too Many Requ } } ``` + +#### User Login + +- **URL:** `/api/auth/login` +- **Method:** `POST` + +- **Request Body**: + + ```json + { + "identifier": "email or username", + "password": "password" + } + ``` + +- **Response**: + + - **Status:** `200 OK` + + ```json + { + "status": "success", + "data": { + "user": {}, + "refreshToken": "JWT_TOKEN", + "accessToken": "JWT_TOKEN" + }, + "message": "User logged in successfully." + } + ``` + + > **Note:** The user data, refresh token, and access token will be returned in the response (`data` field). The access token should be used to access protected routes, and the refresh token should be used to generate a new access token when it expires. + + - **Status:** `400 Bad Request` + + ```json + { + "status": "error", + "message": "Invalid email or username.", + "error": { + "code": "INVALID_IDENTIFIER", + "details": {} + } + } + ``` + + - **Status:** `401 Unauthorized` + + ```json + { + "status": "error", + "message": "Invalid password.", + "error": { + "code": "INVALID_PASSWORD", + "details": {} + } + } + ``` + + - **Status:** `404 Not Found` + + ```json + { + "status": "error", + "message": "User not found.", + "error": { + "code": "USER_NOT_FOUND", + "details": {} + } + } + ``` + + - **Status:** `403 Forbidden` + + ```json + { + "status": "error", + "message": "Account locked.", + "error": { + "code": "ACCOUNT_LOCKED", + "details": {} + } + } + ``` + + - **Status:** `500 Internal Server Error` + + ```json + { + "status": "error", + "message": "Error logging in user.", + "error": { + "code": "LOGIN_USER_ERROR", + "details": {} + } + } + ``` diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authControllers.js similarity index 63% rename from backend/src/controllers/authController.js rename to backend/src/controllers/authControllers.js index 03af420..74e69a0 100644 --- a/backend/src/controllers/authController.js +++ b/backend/src/controllers/authControllers.js @@ -114,7 +114,83 @@ const completeRegistration = async (req, res) => { } }; +/** + * @function loginUser - Handle user login request. + * @param {Request} req - The request object. + * @param {Response} res - The response object. + */ +const loginUser = async (req, res) => { + const { identifier, password } = req.body; + + // Check if the identifier is valid + if (!identifier || !validationUtils.validateIdentifier(identifier)) { + return res.badRequest('Invalid email or username.', 'INVALID_IDENTIFIER'); + } + + // Check if the password is valid + if (!password || !validationUtils.validatePassword(password)) { + return res.badRequest('Invalid password.', 'INVALID_PASSWORD'); + } + + // Log in the user + try { + const user = await userService.loginUser(identifier, password); + const secret = await userService.getRefreshTokenSecret( + user._id, + process.env.JWT_SECRET, + ); + + // Log the user's login + userService.addSafetyRecordById( + user._id, + 'LOGIN_SUCCESS', + req.ip, + req.headers['user-agent'], + ); + + return res.success( + { + user, + refreshToken: jwtService.generateToken( + { userId: user._id }, + secret, + '30d', + ), + accessToken: jwtService.generateToken( + { userId: user._id }, + process.env.JWT_SECRET, + '15m', + ), + }, + 'User logged in successfully.', + ); + } catch (error) { + if (error.message === 'User not found') { + return res.notFound('User not found.', 'USER_NOT_FOUND'); + } + if (error.message === 'Invalid password') { + // Log the failed login attempt + await userService.addSafetyRecordById( + error.user._id, + 'LOGIN_FAILED', + req.ip, + req.headers['user-agent'], + ); + + return res.unauthorized('Invalid password.', 'INVALID_PASSWORD'); + } + if (error.message === 'Account locked') { + return res.forbidden('Account locked.', 'ACCOUNT_LOCKED'); + } + return res.internalServerError( + 'Error logging in user.', + 'LOGIN_USER_ERROR', + ); + } +}; + module.exports = { registerUser, completeRegistration, + loginUser, }; diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js index ccbd3b6..64cd129 100644 --- a/backend/src/routes/authRoutes.js +++ b/backend/src/routes/authRoutes.js @@ -8,20 +8,28 @@ const router = express.Router(); const rateLimiter = require('../utils/rateLimiter'); -const authController = require('../controllers/authController'); +const authControllers = require('../controllers/authControllers'); const RATE_LIMIT_MAX_3 = 3; const RATE_LIMIT_WINDOW_2_MINUTES = 120000; // 2 minutes (2 * 60 * 1000) +const RATE_LIMIT_MAX_15 = 15; +const RATE_LIMIT_WINDOW_1_HOUR = 3600000; // 1 hour (60 * 60 * 1000) router.post( '/register', rateLimiter(RATE_LIMIT_MAX_3, RATE_LIMIT_WINDOW_2_MINUTES), - authController.registerUser, + authControllers.registerUser, ); router.post( '/complete-registration', rateLimiter(), - authController.completeRegistration, + authControllers.completeRegistration, +); + +router.post( + '/login', + rateLimiter(RATE_LIMIT_MAX_15, RATE_LIMIT_WINDOW_1_HOUR), + authControllers.loginUser, ); module.exports = router; From 79d8b42c92558755e0d0cf413a5e7b2b20caae73 Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Mon, 30 Dec 2024 19:16:31 -0800 Subject: [PATCH 19/24] feat(backend/auth): Add refresh token endpoint and update API documentation - Added `refreshToken` endpoint in `authRoutes.js` to handle refresh token requests. - Implemented `refreshToken` controller in `authController.js` to verify the refresh token and generate a new access token. - Updated `API.md` to include documentation for the `refreshToken` endpoint. - Included request body and response examples for the new endpoint. --- backend/docs/API.md | 80 ++++++++++++++++++++++ backend/src/controllers/authControllers.js | 44 ++++++++++++ backend/src/routes/authRoutes.js | 1 + 3 files changed, 125 insertions(+) diff --git a/backend/docs/API.md b/backend/docs/API.md index 9e0200e..c68d1fd 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -43,6 +43,7 @@ If the rate limit is exceeded, the server will respond with a `429 Too Many Requ - [User Registration](#user-registration) - [Complete Registration](#complete-registration) - [User Login](#user-login) + - [Refresh Token](#refresh-token) ### Authentication Endpoints @@ -329,3 +330,82 @@ If the rate limit is exceeded, the server will respond with a `429 Too Many Requ } } ``` + +#### Refresh Token + +- **URL:** `/api/auth/refresh-token` +- **Method:** `POST` + +- **Request Body**: + + ```json + { + "refreshToken": "JWT_TOKEN" + } + ``` + +- **Response**: + + - **Status:** `200 OK` + + ```json + { + "status": "success", + "data": { + "accessToken": "JWT_TOKEN" + }, + "message": "Access token refreshed successfully." + } + ``` + + - **Status:** `400 Bad Request` + + ```json + { + "status": "error", + "message": "Invalid refresh token.", + "error": { + "code": "INVALID_REFRESH_TOKEN", + "details": {} + } + } + ``` + + - **Status:** `400 Bad Request` + + ```json + { + "status": "error", + "message": "Invalid token.", + "error": { + "code": "INVALID_TOKEN", + "details": {} + } + } + ``` + + - **Status:** `401 Unauthorized` + + ```json + { + "status": "error", + "message": "Token expired.", + "error": { + "code": "TOKEN_EXPIRED", + "details": {} + } + } + ``` + + - **Status:** `500 Internal Server Error` + + ```json + { + "status": "error", + "message": "Error refreshing token.", + "error": { + "code": "REFRESH_TOKEN_ERROR", + "details": {} + } + } + ``` diff --git a/backend/src/controllers/authControllers.js b/backend/src/controllers/authControllers.js index 74e69a0..3bf5303 100644 --- a/backend/src/controllers/authControllers.js +++ b/backend/src/controllers/authControllers.js @@ -189,8 +189,52 @@ const loginUser = async (req, res) => { } }; +/** + * @function refreshToken - Handle refresh token request. + * @param {Request} req - The request object. + * @param {Response} res - The response object. + */ +const refreshToken = async (req, res) => { + const { refreshToken: refreshTokenBody } = req.body; + + // Check if the refresh token is valid + if (!refreshToken) { + return res.badRequest('Invalid refresh token.', 'INVALID_REFRESH_TOKEN'); + } + + // Verify the refresh token + try { + const userId = jwtService.decodeToken(refreshTokenBody).userId; + const secret = await userService.getRefreshTokenSecret( + userId, + process.env.JWT_SECRET, + ); + jwtService.verifyToken(refreshTokenBody, secret); + + // Generate a new access token + const accessToken = jwtService.generateToken( + { userId }, + process.env.JWT_SECRET, + '15m', + ); + return res.success({ accessToken }, 'Access token refreshed successfully.'); + } catch (error) { + if (error.name === 'TokenExpiredError') { + return res.unauthorized('Token expired.', 'TOKEN_EXPIRED'); + } + if (error.name === 'JsonWebTokenError') { + return res.badRequest('Invalid token.', 'INVALID_TOKEN'); + } + return res.internalServerError( + 'Error refreshing token.', + 'REFRESH_TOKEN_ERROR', + ); + } +}; + module.exports = { registerUser, completeRegistration, loginUser, + refreshToken, }; diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js index 64cd129..aa1ee3c 100644 --- a/backend/src/routes/authRoutes.js +++ b/backend/src/routes/authRoutes.js @@ -31,5 +31,6 @@ router.post( rateLimiter(RATE_LIMIT_MAX_15, RATE_LIMIT_WINDOW_1_HOUR), authControllers.loginUser, ); +router.post('/refresh-token', rateLimiter(), authControllers.refreshToken); module.exports = router; From 2f7c7174d47e83553ce8e1c5e91bad1c4de0374f Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Mon, 30 Dec 2024 19:26:07 -0800 Subject: [PATCH 20/24] feat(backend/auth): Add reset password endpoint and update API documentation - Added `resetPassword` endpoint in `authRoutes.js` to handle password reset requests. - Implemented `resetPassword` controller in `authController.js` to validate email, generate a reset token, and send a password reset email. - Updated `API.md` to include documentation for the `resetPassword` endpoint. - Included request body and response examples for the new endpoint. --- backend/docs/API.md | 68 ++++++++++++++++++++++ backend/src/controllers/authControllers.js | 49 ++++++++++++++++ backend/src/routes/authRoutes.js | 6 ++ 3 files changed, 123 insertions(+) diff --git a/backend/docs/API.md b/backend/docs/API.md index c68d1fd..89a07ac 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -44,6 +44,7 @@ If the rate limit is exceeded, the server will respond with a `429 Too Many Requ - [Complete Registration](#complete-registration) - [User Login](#user-login) - [Refresh Token](#refresh-token) + - [Reset Password](#reset-password) ### Authentication Endpoints @@ -409,3 +410,70 @@ If the rate limit is exceeded, the server will respond with a `429 Too Many Requ } } ``` + +#### Reset Password + +- **URL:** `/api/auth/reset-password` +- **Method:** `POST` + +- **Request Body**: + + ```json + { + "email": "user@example.com", + "callbackUrl": "https://example.com/reset-password" + } + ``` + + > **Note:** The `callbackUrl` is the URL will be the url sent to the user in the email for email verification. + +- **Response**: + + - **Status:** `200 OK` + + ```json + { + "status": "success", + "data": null, + "message": "Password reset email sent." + } + ``` + + - **Status:** `400 Bad Request` + + ```json + { + "status": "error", + "message": "Invalid email address.", + "error": { + "code": "INVALID_EMAIL", + "details": {} + } + } + ``` + + - **Status:** `404 Not Found` + + ```json + { + "status": "error", + "message": "User not found.", + "error": { + "code": "USER_NOT_FOUND", + "details": {} + } + } + ``` + + - **Status:** `500 Internal Server Error` + + ```json + { + "status": "error", + "message": "Error sending password reset email.", + "error": { + "code": "SEND_EMAIL_ERROR", + "details": {} + } + } + ``` diff --git a/backend/src/controllers/authControllers.js b/backend/src/controllers/authControllers.js index 3bf5303..dada1d2 100644 --- a/backend/src/controllers/authControllers.js +++ b/backend/src/controllers/authControllers.js @@ -232,9 +232,58 @@ const refreshToken = async (req, res) => { } }; +/** + * @function resetPassword - Handle password reset request. + * @param {Request} req - The request object. + * @param {Response} res - The response object. + */ +const resetPassword = async (req, res) => { + const { email, callbackUrl } = req.body; + + // Check if the email is valid + if (!email || !validationUtils.validateEmail(email)) { + return res.badRequest('Invalid email address.', 'INVALID_EMAIL'); + } + + // Check if the user exists + const user = await userService.getUserByEmail(email); + if (!user) { + return res.notFound('User not found.', 'USER_NOT_FOUND'); + } + + // Generate a JWT token with the email (for verification) + const token = jwtService.generateToken( + { email }, + process.env.JWT_SECRET, + '1h', + ); + + // Send the password reset email + try { + await emailService.sendEmailVerification(email, token, callbackUrl); + + // Log the password reset request + userService.addSafetyRecordById( + user._id, + 'PASSWORD_RESET_REQUESTED', + req.ip, + req.headers['user-agent'], + ); + + return res.success(null, 'Password reset email sent.'); + } catch (error) { + console.error('Error sending password reset email: ', error); + return res.internalServerError( + 'Error sending password reset email.', + 'SEND_EMAIL_ERROR', + ); + } +}; + module.exports = { registerUser, completeRegistration, loginUser, refreshToken, + resetPassword, }; diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js index aa1ee3c..b7830f4 100644 --- a/backend/src/routes/authRoutes.js +++ b/backend/src/routes/authRoutes.js @@ -33,4 +33,10 @@ router.post( ); router.post('/refresh-token', rateLimiter(), authControllers.refreshToken); +router.post( + '/reset-password', + rateLimiter(RATE_LIMIT_MAX_3, RATE_LIMIT_WINDOW_2_MINUTES), + authControllers.resetPassword, +); + module.exports = router; From 4ea86d92502efd2566f9474032046df973a6fbbd Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Mon, 30 Dec 2024 20:50:07 -0800 Subject: [PATCH 21/24] feat(backend/auth): Add complete reset password endpoint and update API documentation - Added `completeResetPassword` endpoint in `authRoutes.js` to handle password reset completion requests. - Implemented `completeResetPassword` controller in `authControllers.js` to verify the reset token, validate the new password, and update the user's password. - Updated `API.md` to include documentation for the `completeResetPassword` endpoint. - Included request body and response examples for the new endpoint. --- backend/docs/API.md | 105 +++++++++++++++++++++ backend/src/controllers/authControllers.js | 65 +++++++++++++ backend/src/routes/authRoutes.js | 5 + 3 files changed, 175 insertions(+) diff --git a/backend/docs/API.md b/backend/docs/API.md index 89a07ac..7b733b4 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -45,6 +45,7 @@ If the rate limit is exceeded, the server will respond with a `429 Too Many Requ - [User Login](#user-login) - [Refresh Token](#refresh-token) - [Reset Password](#reset-password) + - [Complete Reset Password](#complete-reset-password) ### Authentication Endpoints @@ -477,3 +478,107 @@ If the rate limit is exceeded, the server will respond with a `429 Too Many Requ } } ``` + +#### Complete Reset Password + +- **URL:** `/api/auth/complete-reset-password` +- **Method:** `POST` + +- **Request Body**: + + ```json + { + "token": "JWT_TOKEN", + "password": "new_password" + } + ``` + +- **Response**: + + - **Status:** `200 OK` + + ```json + { + "status": "success", + "data": {}, + "message": "Password reset successfully." + } + ``` + + - **Status:** `400 Bad Request` + + ```json + { + "status": "error", + "message": "Invalid password.", + "error": { + "code": "INVALID_PASSWORD", + "details": {} + } + } + ``` + + - **Status:** `400 Bad Request` + + ```json + { + "status": "error", + "message": "Invalid token.", + "error": { + "code": "INVALID_TOKEN", + "details": {} + } + } + ``` + + - **Status:** `401 Unauthorized` + + ```json + { + "status": "error", + "message": "Token expired.", + "error": { + "code": "TOKEN_EXPIRED", + "details": {} + } + } + ``` + + - **Status:** `500 Internal Server Error` + + ```json + { + "status": "error", + "message": "Error verifying token.", + "error": { + "code": "VERIFY_TOKEN_ERROR", + "details": {} + } + } + ``` + + - **Status:** `404 Not Found` + + ```json + { + "status": "error", + "message": "User not found.", + "error": { + "code": "USER_NOT_FOUND", + "details": {} + } + } + ``` + + - **Status:** `500 Internal Server Error` + + ```json + { + "status": "error", + "message": "Error resetting password.", + "error": { + "code": "RESET_PASSWORD_ERROR", + "details": {} + } + } + ``` diff --git a/backend/src/controllers/authControllers.js b/backend/src/controllers/authControllers.js index dada1d2..eca2f0b 100644 --- a/backend/src/controllers/authControllers.js +++ b/backend/src/controllers/authControllers.js @@ -280,10 +280,75 @@ const resetPassword = async (req, res) => { } }; +/** + * @function completeResetPassword - Handle password reset completion request. + * @param {Request} req - The request object. + * @param {Response} res - The response object. + */ +const completeResetPassword = async (req, res) => { + const { token, password } = req.body; + + let email; + + // Verify JWT token + try { + const payload = jwtService.verifyToken(token, process.env.JWT_SECRET); + if (!payload.email) { + return res.badRequest('Invalid token.', 'INVALID_TOKEN'); + } + email = payload.email; + } catch (error) { + if (error.name === 'TokenExpiredError') { + return res.unauthorized('Token expired.', 'TOKEN_EXPIRED'); + } + if (error.name === 'JsonWebTokenError') { + return res.badRequest('Invalid token.', 'INVALID_TOKEN'); + } else { + return res.internalServerError( + 'Error verifying token.', + 'VERIFY_TOKEN_ERROR', + ); + } + } + + // Check if the password is valid + if (!password || !validationUtils.validatePassword(password)) { + return res.badRequest('Invalid password.', 'INVALID_PASSWORD'); + } + + // Update the user's password + try { + const user = await userService.getUserByEmail(email); + if (!user) { + return res.notFound('User not found.', 'USER_NOT_FOUND'); + } + + const updatedUser = await userService.updateUserById(user._id, { + password, + }); + + // Log the password reset + userService.addSafetyRecordById( + user._id, + 'PASSWORD_RESET_SUCCESS', + req.ip, + req.headers['user-agent'], + ); + + return res.success(updatedUser, 'Password reset successfully.'); + } catch (error) { + return res.internalServerError( + 'Error resetting password.', + 'RESET_PASSWORD_ERROR', + ); + } +}; + module.exports = { registerUser, completeRegistration, loginUser, refreshToken, resetPassword, + completeResetPassword, }; diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js index b7830f4..8e6c1fa 100644 --- a/backend/src/routes/authRoutes.js +++ b/backend/src/routes/authRoutes.js @@ -38,5 +38,10 @@ router.post( rateLimiter(RATE_LIMIT_MAX_3, RATE_LIMIT_WINDOW_2_MINUTES), authControllers.resetPassword, ); +router.post( + '/complete-reset-password', + rateLimiter(), + authControllers.completeResetPassword, +); module.exports = router; From 1351b8138670af2fa5002d15cac5aa16efd5563c Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Mon, 30 Dec 2024 22:04:11 -0800 Subject: [PATCH 22/24] fix(backend/utils): Add identifier validation function to support email and username checks - Added `validateIdentifier` function in `validationUtils.js` to validate both email and username. - Utilized existing `validateEmail` and `validateUsername` functions within `validateIdentifier`. - Exported `validateIdentifier` function for use in other modules. --- backend/src/utils/validationUtils.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/src/utils/validationUtils.js b/backend/src/utils/validationUtils.js index 5cc6081..10b2310 100644 --- a/backend/src/utils/validationUtils.js +++ b/backend/src/utils/validationUtils.js @@ -63,6 +63,15 @@ const validateUsername = (username) => { } }; +/** + * @function validateIdentifier - Validate an identifier. (email or username) + * @param {string} identifier - The identifier to validate. + * @returns {boolean} - True if the identifier is valid, false otherwise. + */ +const validateIdentifier = (identifier) => { + return validateEmail(identifier) || validateUsername(identifier); +}; + /** * @function validateDisplayName - Validate a display name. * @param {string} displayName - The display name to validate. @@ -157,6 +166,7 @@ module.exports = { validateEmail, validatePassword, validateUsername, + validateIdentifier, validateDisplayName, validateSchool, validateCountry, From 3d9ff7623ac7af7f1ef86b2fb224adf9965405b7 Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Tue, 31 Dec 2024 23:36:21 -0800 Subject: [PATCH 23/24] fix(backend/services): Ensure user service email parameter is safely embedded in MongoDB query - Updated `getUserByEmail` function in `userService.js` to use the `$eq` operator for the email parameter. - This change prevents potential query injection attacks by treating the email input as a literal value. Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- backend/src/services/userService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/services/userService.js b/backend/src/services/userService.js index d532bcc..8e1b7d8 100644 --- a/backend/src/services/userService.js +++ b/backend/src/services/userService.js @@ -106,7 +106,7 @@ const getUserById = async (userId) => { */ const getUserByEmail = async (email) => { try { - const user = await User.findOne({ email }); + const user = await User.findOne({ email: { $eq: email } }); return user; } catch (error) { console.error('Error in getting user by email: ', error); From c08d58f25bcd5b33d0aef8b5a96910ee30f5c4d1 Mon Sep 17 00:00:00 2001 From: Tony Kan Date: Tue, 31 Dec 2024 23:40:30 -0800 Subject: [PATCH 24/24] fix(backend/services): Ensure user service username parameter is safely embedded in MongoDB query - Updated `getUserByUsername` function in `userService.js` to use the `$eq` operator for the username parameter. - This change prevents potential NoSQL injection attacks by treating the username input as a literal value. Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- backend/src/services/userService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/services/userService.js b/backend/src/services/userService.js index 8e1b7d8..f7c0384 100644 --- a/backend/src/services/userService.js +++ b/backend/src/services/userService.js @@ -122,7 +122,7 @@ const getUserByEmail = async (email) => { */ const getUserByUsername = async (username) => { try { - const user = await User.findOne({ username }); + const user = await User.findOne({ username: { $eq: username } }); return user; } catch (error) { console.error('Error in getting user by username: ', error);