password reset

This commit is contained in:
jackiettran
2025-10-10 22:54:45 -04:00
parent 462dbf6b7a
commit b9e6cfc54d
15 changed files with 1976 additions and 178 deletions

View File

@@ -33,6 +33,14 @@ const authenticateToken = async (req, res, next) => {
});
}
// Validate JWT version to invalidate old tokens after password change
if (decoded.jwtVersion !== user.jwtVersion) {
return res.status(401).json({
error: "Session expired due to password change. Please log in again.",
code: "JWT_VERSION_MISMATCH",
});
}
req.user = user;
next();
} catch (error) {
@@ -85,6 +93,12 @@ const optionalAuth = async (req, res, next) => {
return next();
}
// Validate JWT version to invalidate old tokens after password change
if (decoded.jwtVersion !== user.jwtVersion) {
req.user = null;
return next();
}
req.user = user;
next();
} catch (error) {

View File

@@ -260,6 +260,56 @@ const validatePasswordChange = [
handleValidationErrors,
];
// Forgot password validation
const validateForgotPassword = [
body("email")
.isEmail()
.normalizeEmail()
.withMessage("Please provide a valid email address")
.isLength({ max: 255 })
.withMessage("Email must be less than 255 characters"),
handleValidationErrors,
];
// Reset password validation
const validateResetPassword = [
body("token")
.notEmpty()
.withMessage("Reset token is required")
.isLength({ min: 64, max: 64 })
.withMessage("Invalid reset token format"),
body("newPassword")
.isLength({ min: 8, max: 128 })
.withMessage("Password must be between 8 and 128 characters")
.matches(passwordStrengthRegex)
.withMessage(
"Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character"
)
.custom((value) => {
if (commonPasswords.includes(value.toLowerCase())) {
throw new Error(
"Password is too common. Please choose a stronger password"
);
}
return true;
}),
handleValidationErrors,
];
// Verify reset token validation
const validateVerifyResetToken = [
body("token")
.notEmpty()
.withMessage("Reset token is required")
.isLength({ min: 64, max: 64 })
.withMessage("Invalid reset token format"),
handleValidationErrors,
];
module.exports = {
sanitizeInput,
handleValidationErrors,
@@ -268,4 +318,7 @@ module.exports = {
validateGoogleAuth,
validateProfileUpdate,
validatePasswordChange,
validateForgotPassword,
validateResetPassword,
validateVerifyResetToken,
};

View File

@@ -84,6 +84,14 @@ const User = sequelize.define(
type: DataTypes.DATE,
allowNull: true,
},
passwordResetToken: {
type: DataTypes.STRING,
allowNull: true,
},
passwordResetTokenExpiry: {
type: DataTypes.DATE,
allowNull: true,
},
defaultAvailableAfter: {
type: DataTypes.STRING,
defaultValue: "09:00",
@@ -124,6 +132,11 @@ const User = sequelize.define(
type: DataTypes.DATE,
allowNull: true,
},
jwtVersion: {
type: DataTypes.INTEGER,
defaultValue: 0,
allowNull: false,
},
},
{
hooks: {
@@ -222,4 +235,59 @@ User.prototype.verifyEmail = async function () {
});
};
// Password reset methods
User.prototype.generatePasswordResetToken = async function () {
const crypto = require("crypto");
// Generate random token for email URL
const token = crypto.randomBytes(32).toString("hex");
// Hash token before storing in database (SHA-256)
const hashedToken = crypto.createHash("sha256").update(token).digest("hex");
const expiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await this.update({
passwordResetToken: hashedToken,
passwordResetTokenExpiry: expiry,
});
// Return plain token for email URL (not stored in DB)
return token;
};
User.prototype.isPasswordResetTokenValid = function (token) {
if (!this.passwordResetToken || !this.passwordResetTokenExpiry) {
return false;
}
// Check if token is expired first
if (new Date() > new Date(this.passwordResetTokenExpiry)) {
return false;
}
const crypto = require("crypto");
// Hash the incoming token to compare with stored hash
const hashedToken = crypto.createHash("sha256").update(token).digest("hex");
// Use timing-safe comparison to prevent timing attacks
const storedTokenBuffer = Buffer.from(this.passwordResetToken, "hex");
const hashedTokenBuffer = Buffer.from(hashedToken, "hex");
// Ensure buffers are same length for timingSafeEqual
if (storedTokenBuffer.length !== hashedTokenBuffer.length) {
return false;
}
return crypto.timingSafeEqual(storedTokenBuffer, hashedTokenBuffer);
};
User.prototype.resetPassword = async function (newPassword) {
return this.update({
password: newPassword,
passwordResetToken: null,
passwordResetTokenExpiry: null,
// Increment JWT version to invalidate all existing sessions
jwtVersion: this.jwtVersion + 1,
});
};
module.exports = User;

View File

@@ -4,20 +4,30 @@ const { OAuth2Client } = require("google-auth-library");
const { User } = require("../models"); // Import from models/index.js to get models with associations
const logger = require("../utils/logger");
const emailService = require("../services/emailService");
const crypto = require("crypto");
const {
sanitizeInput,
validateRegistration,
validateLogin,
validateGoogleAuth,
validateForgotPassword,
validateResetPassword,
validateVerifyResetToken,
} = require("../middleware/validation");
const { csrfProtection, getCSRFToken } = require("../middleware/csrf");
const { loginLimiter, registerLimiter } = require("../middleware/rateLimiter");
const {
loginLimiter,
registerLimiter,
passwordResetLimiter,
} = require("../middleware/rateLimiter");
const router = express.Router();
const googleClient = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI || "http://localhost:3000/auth/google/callback"
process.env.GOOGLE_REDIRECT_URI ||
"http://localhost:3000/auth/google/callback"
);
// Get CSRF token endpoint
@@ -76,17 +86,19 @@ router.post(
reqLogger.error("Failed to send verification email", {
error: emailError.message,
userId: user.id,
email: user.email
email: user.email,
});
// Continue with registration even if email fails
}
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, {
expiresIn: "15m", // Short-lived access token
});
const token = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_SECRET,
{ expiresIn: "15m" } // Short-lived access token
);
const refreshToken = jwt.sign(
{ id: user.id, type: "refresh" },
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_SECRET,
{ expiresIn: "7d" }
);
@@ -112,7 +124,7 @@ router.post(
reqLogger.info("User registration successful", {
userId: user.id,
username: user.username,
email: user.email
email: user.email,
});
res.status(201).json({
@@ -133,7 +145,7 @@ router.post(
error: error.message,
stack: error.stack,
email: req.body.email,
username: req.body.username
username: req.body.username,
});
res.status(500).json({ error: "Registration failed. Please try again." });
}
@@ -176,12 +188,14 @@ router.post(
// Reset login attempts on successful login
await user.resetLoginAttempts();
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, {
expiresIn: "15m", // Short-lived access token
});
const token = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_SECRET,
{ expiresIn: "15m" } // Short-lived access token
);
const refreshToken = jwt.sign(
{ id: user.id, type: "refresh" },
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_SECRET,
{ expiresIn: "7d" }
);
@@ -206,7 +220,7 @@ router.post(
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("User login successful", {
userId: user.id,
email: user.email
email: user.email,
});
res.json({
@@ -225,7 +239,7 @@ router.post(
reqLogger.error("Login error", {
error: error.message,
stack: error.stack,
email: req.body.email
email: req.body.email,
});
res.status(500).json({ error: "Login failed. Please try again." });
}
@@ -243,13 +257,17 @@ router.post(
const { code } = req.body;
if (!code) {
return res.status(400).json({ error: "Authorization code is required" });
return res
.status(400)
.json({ error: "Authorization code is required" });
}
// Exchange authorization code for tokens
const { tokens } = await googleClient.getToken({
code,
redirect_uri: process.env.GOOGLE_REDIRECT_URI || "http://localhost:3000/auth/google/callback",
redirect_uri:
process.env.GOOGLE_REDIRECT_URI ||
"http://localhost:3000/auth/google/callback",
});
// Verify the ID token from the token response
@@ -303,12 +321,14 @@ router.post(
}
// Generate JWT tokens
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, {
expiresIn: "15m",
});
const token = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_SECRET,
{ expiresIn: "15m" }
);
const refreshToken = jwt.sign(
{ id: user.id, type: "refresh" },
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_SECRET,
{ expiresIn: "7d" }
);
@@ -334,7 +354,9 @@ router.post(
reqLogger.info("Google authentication successful", {
userId: user.id,
email: user.email,
isNewUser: !user.createdAt || (Date.now() - new Date(user.createdAt).getTime()) < 1000
isNewUser:
!user.createdAt ||
Date.now() - new Date(user.createdAt).getTime() < 1000,
});
res.json({
@@ -351,9 +373,9 @@ router.post(
});
} catch (error) {
if (error.message && error.message.includes("invalid_grant")) {
return res
.status(401)
.json({ error: "Invalid or expired authorization code. Please try again." });
return res.status(401).json({
error: "Invalid or expired authorization code. Please try again.",
});
}
if (error.message && error.message.includes("redirect_uri_mismatch")) {
return res
@@ -364,7 +386,7 @@ router.post(
reqLogger.error("Google OAuth error", {
error: error.message,
stack: error.stack,
codePresent: !!req.body.code
codePresent: !!req.body.code,
});
res
.status(500)
@@ -546,10 +568,20 @@ router.post("/refresh", async (req, res) => {
return res.status(401).json({ error: "User not found" });
}
// Validate JWT version to invalidate old tokens after password change
if (decoded.jwtVersion !== user.jwtVersion) {
return res.status(401).json({
error: "Session expired due to password change. Please log in again.",
code: "JWT_VERSION_MISMATCH",
});
}
// Generate new access token
const newAccessToken = jwt.sign({ id: user.id }, process.env.JWT_SECRET, {
expiresIn: "15m",
});
const newAccessToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_SECRET,
{ expiresIn: "15m" }
);
// Set new access token cookie
res.cookie("accessToken", newAccessToken, {
@@ -561,7 +593,7 @@ router.post("/refresh", async (req, res) => {
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Token refresh successful", {
userId: user.id
userId: user.id,
});
res.json({
@@ -579,7 +611,7 @@ router.post("/refresh", async (req, res) => {
reqLogger.error("Token refresh error", {
error: error.message,
stack: error.stack,
userId: req.user?.id
userId: req.user?.id,
});
res.status(401).json({ error: "Invalid or expired refresh token" });
}
@@ -589,7 +621,7 @@ router.post("/refresh", async (req, res) => {
router.post("/logout", (req, res) => {
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("User logout", {
userId: req.user?.id || 'anonymous'
userId: req.user?.id || "anonymous",
});
// Clear cookies
@@ -604,13 +636,221 @@ router.get("/status", optionalAuth, async (req, res) => {
if (req.user) {
res.json({
authenticated: true,
user: req.user
user: req.user,
});
} else {
res.json({
authenticated: false
authenticated: false,
});
}
});
// Forgot password endpoint
router.post(
"/forgot-password",
passwordResetLimiter,
csrfProtection,
sanitizeInput,
validateForgotPassword,
async (req, res) => {
try {
const { email } = req.body;
// Find user with local auth provider only
const user = await User.findOne({
where: {
email,
authProvider: "local",
},
});
// Always return success to prevent email enumeration
// Don't reveal whether the email exists or not
if (user) {
// Generate password reset token (returns plain token for email)
const resetToken = await user.generatePasswordResetToken();
// Send password reset email
try {
await emailService.sendPasswordResetEmail(user, resetToken);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Password reset email sent", {
userId: user.id,
email: user.email,
});
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to send password reset email", {
error: emailError.message,
userId: user.id,
email: user.email,
});
// Continue - don't reveal email sending failure to user
}
} else {
const reqLogger = logger.withRequestId(req.id);
reqLogger.info(
"Password reset requested for non-existent or OAuth user",
{
email: email,
}
);
}
// Always return success message (security best practice)
res.json({
message:
"If an account exists with that email, you will receive password reset instructions.",
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Forgot password error", {
error: error.message,
stack: error.stack,
email: req.body.email,
});
res.status(500).json({
error: "Failed to process password reset request. Please try again.",
});
}
}
);
// Verify reset token endpoint (optional - for frontend UX)
router.post(
"/verify-reset-token",
sanitizeInput,
validateVerifyResetToken,
async (req, res) => {
try {
const { token } = req.body;
// Hash the token to search for it in the database
const hashedToken = crypto
.createHash("sha256")
.update(token)
.digest("hex");
// Find user with this reset token (hashed)
const user = await User.findOne({
where: { passwordResetToken: hashedToken },
});
if (!user) {
return res.status(400).json({
valid: false,
error: "Invalid reset token",
code: "TOKEN_INVALID",
});
}
// Check if token is valid (not expired)
if (!user.isPasswordResetTokenValid(token)) {
return res.status(400).json({
valid: false,
error: "Reset token has expired. Please request a new one.",
code: "TOKEN_EXPIRED",
});
}
res.json({
valid: true,
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Verify reset token error", {
error: error.message,
stack: error.stack,
});
res.status(500).json({
valid: false,
error: "Failed to verify reset token. Please try again.",
});
}
}
);
// Reset password endpoint
router.post(
"/reset-password",
passwordResetLimiter,
csrfProtection,
sanitizeInput,
validateResetPassword,
async (req, res) => {
try {
const { token, newPassword } = req.body;
// Hash the token to search for it in the database
const crypto = require("crypto");
const hashedToken = crypto
.createHash("sha256")
.update(token)
.digest("hex");
// Find user with this reset token (hashed)
const user = await User.findOne({
where: { passwordResetToken: hashedToken },
});
if (!user) {
return res.status(400).json({
error: "Invalid or expired reset token",
code: "TOKEN_INVALID",
});
}
// Check if token is valid (not expired)
if (!user.isPasswordResetTokenValid(token)) {
return res.status(400).json({
error: "Reset token has expired. Please request a new one.",
code: "TOKEN_EXPIRED",
});
}
// Reset password (this will clear the token and hash the new password)
await user.resetPassword(newPassword);
// Send password changed notification email
try {
await emailService.sendPasswordChangedEmail(user);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Password changed notification sent", {
userId: user.id,
email: user.email,
});
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to send password changed notification", {
error: emailError.message,
userId: user.id,
email: user.email,
});
// Continue - don't fail password reset if email fails
}
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Password reset successful", {
userId: user.id,
email: user.email,
});
res.json({
message:
"Password has been reset successfully. You can now log in with your new password.",
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Reset password error", {
error: error.message,
stack: error.stack,
});
res.status(500).json({
error: "Failed to reset password. Please try again.",
});
}
}
);
module.exports = router;

View File

@@ -36,6 +36,8 @@ class EmailService {
"conditionCheckReminder.html",
"rentalConfirmation.html",
"emailVerification.html",
"passwordReset.html",
"passwordChanged.html",
"lateReturnCS.html",
"damageReportCS.html",
"lostItemCS.html",
@@ -244,6 +246,31 @@ class EmailService {
<p><strong>This link will expire in 24 hours.</strong></p>
`
),
passwordReset: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>Reset Your Password</h2>
<p>We received a request to reset the password for your RentAll account. Click the button below to choose a new password.</p>
<p><a href="{{resetUrl}}" class="button">Reset Password</a></p>
<p>If the button doesn't work, copy and paste this link into your browser: {{resetUrl}}</p>
<p><strong>This link will expire in 1 hour.</strong></p>
<p>If you didn't request this, you can safely ignore this email.</p>
`
),
passwordChanged: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>Your Password Has Been Changed</h2>
<p>This is a confirmation that the password for your RentAll account ({{email}}) has been successfully changed.</p>
<p><strong>Changed on:</strong> {{timestamp}}</p>
<p>For your security, all existing sessions have been logged out.</p>
<p><strong>Didn't change your password?</strong> If you did not make this change, please contact our support team immediately.</p>
`
),
};
return (
@@ -326,6 +353,45 @@ class EmailService {
);
}
async sendPasswordResetEmail(user, resetToken) {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const resetUrl = `${frontendUrl}/reset-password?token=${resetToken}`;
const variables = {
recipientName: user.firstName || "there",
resetUrl: resetUrl,
};
const htmlContent = this.renderTemplate("passwordReset", variables);
return await this.sendEmail(
user.email,
"Reset Your Password - RentAll",
htmlContent
);
}
async sendPasswordChangedEmail(user) {
const timestamp = new Date().toLocaleString("en-US", {
dateStyle: "long",
timeStyle: "short",
});
const variables = {
recipientName: user.firstName || "there",
email: user.email,
timestamp: timestamp,
};
const htmlContent = this.renderTemplate("passwordChanged", variables);
return await this.sendEmail(
user.email,
"Password Changed Successfully - RentAll",
htmlContent
);
}
async sendTemplateEmail(toEmail, subject, templateName, variables = {}) {
const htmlContent = this.renderTemplate(templateName, variables);
return await this.sendEmail(toEmail, subject, htmlContent);

View File

@@ -0,0 +1,246 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Password Changed Successfully - RentAll</title>
<style>
/* Reset styles */
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #d4edda;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Success box */
.success-box {
background-color: #d4edda;
border-left: 4px solid #28a745;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.success-box p {
margin: 0;
color: #155724;
font-size: 14px;
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
font-size: 14px;
}
/* Security box */
.security-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 15px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.security-box p {
margin: 0;
color: #721c24;
font-size: 14px;
}
/* Details table */
.details-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
.details-table td {
padding: 12px;
border-bottom: 1px solid #e9ecef;
}
.details-table td:first-child {
font-weight: 600;
color: #495057;
width: 40%;
}
.details-table td:last-child {
color: #6c757d;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header, .content, .footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">Password Changed Successfully</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>Your Password Has Been Changed</h1>
<div class="success-box">
<p><strong>Your password was successfully changed.</strong> You can now use your new password to log in to your RentAll account.</p>
</div>
<p>This is a confirmation that the password for your RentAll account has been changed. For your security, all existing sessions have been logged out.</p>
<table class="details-table">
<tr>
<td>Date & Time:</td>
<td>{{timestamp}}</td>
</tr>
<tr>
<td>Account Email:</td>
<td>{{email}}</td>
</tr>
</table>
<div class="security-box">
<p><strong>Didn't change your password?</strong> If you did not make this change, your account may be compromised. Please contact our support team immediately at support@rentall.com to secure your account.</p>
</div>
<div class="info-box">
<p><strong>Security reminder:</strong> Keep your password secure and never share it with anyone. We recommend using a strong, unique password and enabling two-factor authentication when available.</p>
</div>
<p>Thanks for using RentAll!</p>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p>This is a security notification sent to confirm your password change. If you have any concerns about your account security, please contact our support team immediately.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,246 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Reset Your Password - RentAll</title>
<style>
/* Reset styles */
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #d4edda;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
font-size: 14px;
}
/* Warning box */
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0;
color: #856404;
font-size: 14px;
}
/* Security box */
.security-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 15px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.security-box p {
margin: 0;
color: #721c24;
font-size: 14px;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header, .content, .footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">Password Reset Request</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>Reset Your Password</h1>
<p>We received a request to reset the password for your RentAll account. Click the button below to choose a new password.</p>
<div style="text-align: center;">
<a href="{{resetUrl}}" class="button">Reset Password</a>
</div>
<p>If the button doesn't work, you can copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #667eea;">{{resetUrl}}</p>
<div class="warning-box">
<p><strong>This link will expire in 1 hour.</strong> For security reasons, password reset links are only valid for a short time. If your link has expired, you can request a new one.</p>
</div>
<div class="security-box">
<p><strong>Didn't request a password reset?</strong> If you didn't request this, you can safely ignore this email. Your password will not be changed. However, if you're concerned about your account security, please contact our support team immediately.</p>
</div>
<div class="info-box">
<p><strong>Security tip:</strong> Choose a strong password that includes a mix of uppercase and lowercase letters, numbers, and special characters. Never share your password with anyone.</p>
</div>
<p>Thanks for using RentAll!</p>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p>This is a transactional email sent in response to a password reset request. You received this message because someone requested a password reset for this email address.</p>
<p>If you have any questions or concerns, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,293 @@
const crypto = require('crypto');
// Mock crypto module
jest.mock('crypto');
// Mock the entire models module
jest.mock('../../../models', () => {
const mockUser = {
update: jest.fn(),
passwordResetToken: null,
passwordResetTokenExpiry: null,
};
return {
User: mockUser,
sequelize: {
models: {
User: mockUser
}
}
};
});
// Import User model methods - we'll test them directly
const User = require('../../../models/User');
describe('User Model - Password Reset', () => {
let mockUser;
beforeEach(() => {
jest.clearAllMocks();
// Create a fresh mock user for each test
mockUser = {
id: 1,
email: 'test@example.com',
firstName: 'Test',
lastName: 'User',
password: 'hashedPassword123',
passwordResetToken: null,
passwordResetTokenExpiry: null,
update: jest.fn().mockImplementation(function(updates) {
Object.assign(this, updates);
return Promise.resolve(this);
})
};
// Add the prototype methods to mockUser
Object.setPrototypeOf(mockUser, User.prototype);
});
describe('generatePasswordResetToken', () => {
it('should generate a random token and set 1-hour expiry', async () => {
const mockRandomBytes = Buffer.from('a'.repeat(32));
const mockToken = mockRandomBytes.toString('hex');
crypto.randomBytes.mockReturnValue(mockRandomBytes);
await User.prototype.generatePasswordResetToken.call(mockUser);
expect(crypto.randomBytes).toHaveBeenCalledWith(32);
expect(mockUser.update).toHaveBeenCalledWith(
expect.objectContaining({
passwordResetToken: mockToken
})
);
// Check that expiry is approximately 1 hour from now
const updateCall = mockUser.update.mock.calls[0][0];
const expiryTime = updateCall.passwordResetTokenExpiry.getTime();
const expectedExpiry = Date.now() + 60 * 60 * 1000;
expect(expiryTime).toBeGreaterThan(expectedExpiry - 1000);
expect(expiryTime).toBeLessThan(expectedExpiry + 1000);
});
it('should update the user with token and expiry', async () => {
const mockRandomBytes = Buffer.from('b'.repeat(32));
const mockToken = mockRandomBytes.toString('hex');
crypto.randomBytes.mockReturnValue(mockRandomBytes);
const result = await User.prototype.generatePasswordResetToken.call(mockUser);
expect(mockUser.update).toHaveBeenCalledTimes(1);
expect(result.passwordResetToken).toBe(mockToken);
expect(result.passwordResetTokenExpiry).toBeInstanceOf(Date);
});
it('should generate unique tokens on multiple calls', async () => {
const mockRandomBytes1 = Buffer.from('a'.repeat(32));
const mockRandomBytes2 = Buffer.from('b'.repeat(32));
crypto.randomBytes
.mockReturnValueOnce(mockRandomBytes1)
.mockReturnValueOnce(mockRandomBytes2);
await User.prototype.generatePasswordResetToken.call(mockUser);
const firstToken = mockUser.update.mock.calls[0][0].passwordResetToken;
await User.prototype.generatePasswordResetToken.call(mockUser);
const secondToken = mockUser.update.mock.calls[1][0].passwordResetToken;
expect(firstToken).not.toBe(secondToken);
});
});
describe('isPasswordResetTokenValid', () => {
it('should return true for valid token and non-expired time', () => {
const validToken = 'valid-token-123';
const futureExpiry = new Date(Date.now() + 30 * 60 * 1000); // 30 minutes from now
mockUser.passwordResetToken = validToken;
mockUser.passwordResetTokenExpiry = futureExpiry;
const result = User.prototype.isPasswordResetTokenValid.call(mockUser, validToken);
expect(result).toBe(true);
});
it('should return false for missing token', () => {
mockUser.passwordResetToken = null;
mockUser.passwordResetTokenExpiry = new Date(Date.now() + 30 * 60 * 1000);
const result = User.prototype.isPasswordResetTokenValid.call(mockUser, 'any-token');
expect(result).toBe(false);
});
it('should return false for missing expiry', () => {
mockUser.passwordResetToken = 'valid-token';
mockUser.passwordResetTokenExpiry = null;
const result = User.prototype.isPasswordResetTokenValid.call(mockUser, 'valid-token');
expect(result).toBe(false);
});
it('should return false for mismatched token', () => {
mockUser.passwordResetToken = 'correct-token';
mockUser.passwordResetTokenExpiry = new Date(Date.now() + 30 * 60 * 1000);
const result = User.prototype.isPasswordResetTokenValid.call(mockUser, 'wrong-token');
expect(result).toBe(false);
});
it('should return false for expired token', () => {
const validToken = 'valid-token-123';
const pastExpiry = new Date(Date.now() - 10 * 60 * 1000); // 10 minutes ago
mockUser.passwordResetToken = validToken;
mockUser.passwordResetTokenExpiry = pastExpiry;
const result = User.prototype.isPasswordResetTokenValid.call(mockUser, validToken);
expect(result).toBe(false);
});
it('should return false for token expiring in the past by 1 second', () => {
const validToken = 'valid-token-123';
const pastExpiry = new Date(Date.now() - 1000); // 1 second ago
mockUser.passwordResetToken = validToken;
mockUser.passwordResetTokenExpiry = pastExpiry;
const result = User.prototype.isPasswordResetTokenValid.call(mockUser, validToken);
expect(result).toBe(false);
});
it('should handle edge case of token expiring exactly now', () => {
const validToken = 'valid-token-123';
// Set expiry 1ms in the future to handle timing precision
const nowExpiry = new Date(Date.now() + 1);
mockUser.passwordResetToken = validToken;
mockUser.passwordResetTokenExpiry = nowExpiry;
// This should be true because expiry is slightly in the future
const result = User.prototype.isPasswordResetTokenValid.call(mockUser, validToken);
expect(result).toBe(true);
});
it('should handle string dates correctly', () => {
const validToken = 'valid-token-123';
const futureExpiry = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // String date
mockUser.passwordResetToken = validToken;
mockUser.passwordResetTokenExpiry = futureExpiry;
const result = User.prototype.isPasswordResetTokenValid.call(mockUser, validToken);
expect(result).toBe(true);
});
});
describe('resetPassword', () => {
it('should update password and clear token fields', async () => {
mockUser.passwordResetToken = 'some-token';
mockUser.passwordResetTokenExpiry = new Date();
await User.prototype.resetPassword.call(mockUser, 'newSecurePassword123!');
expect(mockUser.update).toHaveBeenCalledWith({
password: 'newSecurePassword123!',
passwordResetToken: null,
passwordResetTokenExpiry: null
});
});
it('should return updated user object', async () => {
const result = await User.prototype.resetPassword.call(mockUser, 'newPassword123!');
expect(result.password).toBe('newPassword123!');
expect(result.passwordResetToken).toBe(null);
expect(result.passwordResetTokenExpiry).toBe(null);
});
it('should call update only once', async () => {
await User.prototype.resetPassword.call(mockUser, 'newPassword123!');
expect(mockUser.update).toHaveBeenCalledTimes(1);
});
});
describe('Complete password reset flow', () => {
it('should complete full password reset flow successfully', async () => {
// Step 1: Generate password reset token
const mockRandomBytes = Buffer.from('c'.repeat(32));
const mockToken = mockRandomBytes.toString('hex');
crypto.randomBytes.mockReturnValue(mockRandomBytes);
await User.prototype.generatePasswordResetToken.call(mockUser);
expect(mockUser.passwordResetToken).toBe(mockToken);
expect(mockUser.passwordResetTokenExpiry).toBeInstanceOf(Date);
// Step 2: Validate token
const isValid = User.prototype.isPasswordResetTokenValid.call(mockUser, mockToken);
expect(isValid).toBe(true);
// Step 3: Reset password
await User.prototype.resetPassword.call(mockUser, 'newPassword123!');
expect(mockUser.password).toBe('newPassword123!');
expect(mockUser.passwordResetToken).toBe(null);
expect(mockUser.passwordResetTokenExpiry).toBe(null);
});
it('should fail password reset with wrong token', async () => {
// Generate token
const mockToken = 'd'.repeat(64);
const mockRandomBytes = Buffer.from('d'.repeat(32));
crypto.randomBytes.mockReturnValue(mockRandomBytes);
await User.prototype.generatePasswordResetToken.call(mockUser);
// Try to validate with wrong token
const isValid = User.prototype.isPasswordResetTokenValid.call(mockUser, 'wrong-token');
expect(isValid).toBe(false);
});
it('should fail password reset with expired token', async () => {
// Manually set an expired token
mockUser.passwordResetToken = 'expired-token';
mockUser.passwordResetTokenExpiry = new Date(Date.now() - 2 * 60 * 60 * 1000); // 2 hours ago
const isValid = User.prototype.isPasswordResetTokenValid.call(mockUser, 'expired-token');
expect(isValid).toBe(false);
});
it('should not allow password reset after token has been used', async () => {
// Generate token
const mockRandomBytes = Buffer.from('e'.repeat(32));
const mockToken = mockRandomBytes.toString('hex');
crypto.randomBytes.mockReturnValue(mockRandomBytes);
await User.prototype.generatePasswordResetToken.call(mockUser);
// Reset password (clears token)
await User.prototype.resetPassword.call(mockUser, 'newPassword123!');
// Try to validate same token again (should fail because it's cleared)
const isValid = User.prototype.isPasswordResetTokenValid.call(mockUser, mockToken);
expect(isValid).toBe(false);
});
});
});

View File

@@ -0,0 +1,296 @@
const crypto = require('crypto');
// Mock crypto module
jest.mock('crypto');
// Mock the entire models module
jest.mock('../../../models', () => {
const mockUser = {
update: jest.fn(),
verificationToken: null,
verificationTokenExpiry: null,
isVerified: false,
verifiedAt: null
};
return {
User: mockUser,
sequelize: {
models: {
User: mockUser
}
}
};
});
// Import User model methods - we'll test them directly
const User = require('../../../models/User');
describe('User Model - Email Verification', () => {
let mockUser;
beforeEach(() => {
jest.clearAllMocks();
// Create a fresh mock user for each test
mockUser = {
id: 1,
email: 'test@example.com',
firstName: 'Test',
lastName: 'User',
verificationToken: null,
verificationTokenExpiry: null,
isVerified: false,
verifiedAt: null,
update: jest.fn().mockImplementation(function(updates) {
Object.assign(this, updates);
return Promise.resolve(this);
})
};
// Add the prototype methods to mockUser
Object.setPrototypeOf(mockUser, User.prototype);
});
describe('generateVerificationToken', () => {
it('should generate a random token and set 24-hour expiry', async () => {
const mockRandomBytes = Buffer.from('a'.repeat(32));
const mockToken = mockRandomBytes.toString('hex'); // This will be "61" repeated 32 times
crypto.randomBytes.mockReturnValue(mockRandomBytes);
await User.prototype.generateVerificationToken.call(mockUser);
expect(crypto.randomBytes).toHaveBeenCalledWith(32);
expect(mockUser.update).toHaveBeenCalledWith(
expect.objectContaining({
verificationToken: mockToken
})
);
// Check that expiry is approximately 24 hours from now
const updateCall = mockUser.update.mock.calls[0][0];
const expiryTime = updateCall.verificationTokenExpiry.getTime();
const expectedExpiry = Date.now() + 24 * 60 * 60 * 1000;
expect(expiryTime).toBeGreaterThan(expectedExpiry - 1000);
expect(expiryTime).toBeLessThan(expectedExpiry + 1000);
});
it('should update the user with token and expiry', async () => {
const mockRandomBytes = Buffer.from('b'.repeat(32));
const mockToken = mockRandomBytes.toString('hex');
crypto.randomBytes.mockReturnValue(mockRandomBytes);
const result = await User.prototype.generateVerificationToken.call(mockUser);
expect(mockUser.update).toHaveBeenCalledTimes(1);
expect(result.verificationToken).toBe(mockToken);
expect(result.verificationTokenExpiry).toBeInstanceOf(Date);
});
it('should generate unique tokens on multiple calls', async () => {
const mockRandomBytes1 = Buffer.from('a'.repeat(32));
const mockRandomBytes2 = Buffer.from('b'.repeat(32));
crypto.randomBytes
.mockReturnValueOnce(mockRandomBytes1)
.mockReturnValueOnce(mockRandomBytes2);
await User.prototype.generateVerificationToken.call(mockUser);
const firstToken = mockUser.update.mock.calls[0][0].verificationToken;
await User.prototype.generateVerificationToken.call(mockUser);
const secondToken = mockUser.update.mock.calls[1][0].verificationToken;
expect(firstToken).not.toBe(secondToken);
});
});
describe('isVerificationTokenValid', () => {
it('should return true for valid token and non-expired time', () => {
const validToken = 'valid-token-123';
const futureExpiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
mockUser.verificationToken = validToken;
mockUser.verificationTokenExpiry = futureExpiry;
const result = User.prototype.isVerificationTokenValid.call(mockUser, validToken);
expect(result).toBe(true);
});
it('should return false for missing token', () => {
mockUser.verificationToken = null;
mockUser.verificationTokenExpiry = new Date(Date.now() + 60 * 60 * 1000);
const result = User.prototype.isVerificationTokenValid.call(mockUser, 'any-token');
expect(result).toBe(false);
});
it('should return false for missing expiry', () => {
mockUser.verificationToken = 'valid-token';
mockUser.verificationTokenExpiry = null;
const result = User.prototype.isVerificationTokenValid.call(mockUser, 'valid-token');
expect(result).toBe(false);
});
it('should return false for mismatched token', () => {
mockUser.verificationToken = 'correct-token';
mockUser.verificationTokenExpiry = new Date(Date.now() + 60 * 60 * 1000);
const result = User.prototype.isVerificationTokenValid.call(mockUser, 'wrong-token');
expect(result).toBe(false);
});
it('should return false for expired token', () => {
const validToken = 'valid-token-123';
const pastExpiry = new Date(Date.now() - 60 * 60 * 1000); // 1 hour ago
mockUser.verificationToken = validToken;
mockUser.verificationTokenExpiry = pastExpiry;
const result = User.prototype.isVerificationTokenValid.call(mockUser, validToken);
expect(result).toBe(false);
});
it('should return false for token expiring in the past by 1 second', () => {
const validToken = 'valid-token-123';
const pastExpiry = new Date(Date.now() - 1000); // 1 second ago
mockUser.verificationToken = validToken;
mockUser.verificationTokenExpiry = pastExpiry;
const result = User.prototype.isVerificationTokenValid.call(mockUser, validToken);
expect(result).toBe(false);
});
it('should handle edge case of token expiring exactly now', () => {
const validToken = 'valid-token-123';
// Set expiry 1ms in the future to handle timing precision
const nowExpiry = new Date(Date.now() + 1);
mockUser.verificationToken = validToken;
mockUser.verificationTokenExpiry = nowExpiry;
// This should be true because expiry is slightly in the future
const result = User.prototype.isVerificationTokenValid.call(mockUser, validToken);
expect(result).toBe(true);
});
it('should handle string dates correctly', () => {
const validToken = 'valid-token-123';
const futureExpiry = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // String date
mockUser.verificationToken = validToken;
mockUser.verificationTokenExpiry = futureExpiry;
const result = User.prototype.isVerificationTokenValid.call(mockUser, validToken);
expect(result).toBe(true);
});
});
describe('verifyEmail', () => {
it('should mark user as verified and clear token fields', async () => {
mockUser.verificationToken = 'some-token';
mockUser.verificationTokenExpiry = new Date();
await User.prototype.verifyEmail.call(mockUser);
expect(mockUser.update).toHaveBeenCalledWith(
expect.objectContaining({
isVerified: true,
verificationToken: null,
verificationTokenExpiry: null
})
);
});
it('should set verifiedAt timestamp', async () => {
const beforeTime = Date.now();
await User.prototype.verifyEmail.call(mockUser);
const updateCall = mockUser.update.mock.calls[0][0];
const verifiedAtTime = updateCall.verifiedAt.getTime();
const afterTime = Date.now();
expect(verifiedAtTime).toBeGreaterThanOrEqual(beforeTime);
expect(verifiedAtTime).toBeLessThanOrEqual(afterTime);
});
it('should return updated user object', async () => {
const result = await User.prototype.verifyEmail.call(mockUser);
expect(result.isVerified).toBe(true);
expect(result.verificationToken).toBe(null);
expect(result.verificationTokenExpiry).toBe(null);
expect(result.verifiedAt).toBeInstanceOf(Date);
});
it('should call update only once', async () => {
await User.prototype.verifyEmail.call(mockUser);
expect(mockUser.update).toHaveBeenCalledTimes(1);
});
});
describe('Complete verification flow', () => {
it('should complete full verification flow successfully', async () => {
// Step 1: Generate verification token
const mockRandomBytes = Buffer.from('c'.repeat(32));
const mockToken = mockRandomBytes.toString('hex');
crypto.randomBytes.mockReturnValue(mockRandomBytes);
await User.prototype.generateVerificationToken.call(mockUser);
expect(mockUser.verificationToken).toBe(mockToken);
expect(mockUser.verificationTokenExpiry).toBeInstanceOf(Date);
// Step 2: Validate token
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, mockToken);
expect(isValid).toBe(true);
// Step 3: Verify email
await User.prototype.verifyEmail.call(mockUser);
expect(mockUser.isVerified).toBe(true);
expect(mockUser.verificationToken).toBe(null);
expect(mockUser.verificationTokenExpiry).toBe(null);
expect(mockUser.verifiedAt).toBeInstanceOf(Date);
});
it('should fail verification with wrong token', async () => {
// Generate token
const mockToken = 'd'.repeat(64);
const mockRandomBytes = Buffer.from('d'.repeat(32));
crypto.randomBytes.mockReturnValue(mockRandomBytes);
await User.prototype.generateVerificationToken.call(mockUser);
// Try to validate with wrong token
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, 'wrong-token');
expect(isValid).toBe(false);
});
it('should fail verification with expired token', async () => {
// Manually set an expired token
mockUser.verificationToken = 'expired-token';
mockUser.verificationTokenExpiry = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 hours ago
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, 'expired-token');
expect(isValid).toBe(false);
});
});
});