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; req.user = user;
next(); next();
} catch (error) { } catch (error) {
@@ -85,6 +93,12 @@ const optionalAuth = async (req, res, next) => {
return 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; req.user = user;
next(); next();
} catch (error) { } catch (error) {

View File

@@ -260,6 +260,56 @@ const validatePasswordChange = [
handleValidationErrors, 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 = { module.exports = {
sanitizeInput, sanitizeInput,
handleValidationErrors, handleValidationErrors,
@@ -268,4 +318,7 @@ module.exports = {
validateGoogleAuth, validateGoogleAuth,
validateProfileUpdate, validateProfileUpdate,
validatePasswordChange, validatePasswordChange,
validateForgotPassword,
validateResetPassword,
validateVerifyResetToken,
}; };

View File

@@ -84,6 +84,14 @@ const User = sequelize.define(
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: true, allowNull: true,
}, },
passwordResetToken: {
type: DataTypes.STRING,
allowNull: true,
},
passwordResetTokenExpiry: {
type: DataTypes.DATE,
allowNull: true,
},
defaultAvailableAfter: { defaultAvailableAfter: {
type: DataTypes.STRING, type: DataTypes.STRING,
defaultValue: "09:00", defaultValue: "09:00",
@@ -124,6 +132,11 @@ const User = sequelize.define(
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: true, allowNull: true,
}, },
jwtVersion: {
type: DataTypes.INTEGER,
defaultValue: 0,
allowNull: false,
},
}, },
{ {
hooks: { 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; 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 { User } = require("../models"); // Import from models/index.js to get models with associations
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const emailService = require("../services/emailService"); const emailService = require("../services/emailService");
const crypto = require("crypto");
const { const {
sanitizeInput, sanitizeInput,
validateRegistration, validateRegistration,
validateLogin, validateLogin,
validateGoogleAuth, validateGoogleAuth,
validateForgotPassword,
validateResetPassword,
validateVerifyResetToken,
} = require("../middleware/validation"); } = require("../middleware/validation");
const { csrfProtection, getCSRFToken } = require("../middleware/csrf"); const { csrfProtection, getCSRFToken } = require("../middleware/csrf");
const { loginLimiter, registerLimiter } = require("../middleware/rateLimiter"); const {
loginLimiter,
registerLimiter,
passwordResetLimiter,
} = require("../middleware/rateLimiter");
const router = express.Router(); const router = express.Router();
const googleClient = new OAuth2Client( const googleClient = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET, 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 // Get CSRF token endpoint
@@ -76,17 +86,19 @@ router.post(
reqLogger.error("Failed to send verification email", { reqLogger.error("Failed to send verification email", {
error: emailError.message, error: emailError.message,
userId: user.id, userId: user.id,
email: user.email email: user.email,
}); });
// Continue with registration even if email fails // Continue with registration even if email fails
} }
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { const token = jwt.sign(
expiresIn: "15m", // Short-lived access token { id: user.id, jwtVersion: user.jwtVersion },
}); process.env.JWT_SECRET,
{ expiresIn: "15m" } // Short-lived access token
);
const refreshToken = jwt.sign( const refreshToken = jwt.sign(
{ id: user.id, type: "refresh" }, { id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_SECRET, process.env.JWT_SECRET,
{ expiresIn: "7d" } { expiresIn: "7d" }
); );
@@ -112,7 +124,7 @@ router.post(
reqLogger.info("User registration successful", { reqLogger.info("User registration successful", {
userId: user.id, userId: user.id,
username: user.username, username: user.username,
email: user.email email: user.email,
}); });
res.status(201).json({ res.status(201).json({
@@ -133,7 +145,7 @@ router.post(
error: error.message, error: error.message,
stack: error.stack, stack: error.stack,
email: req.body.email, email: req.body.email,
username: req.body.username username: req.body.username,
}); });
res.status(500).json({ error: "Registration failed. Please try again." }); res.status(500).json({ error: "Registration failed. Please try again." });
} }
@@ -176,12 +188,14 @@ router.post(
// Reset login attempts on successful login // Reset login attempts on successful login
await user.resetLoginAttempts(); await user.resetLoginAttempts();
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { const token = jwt.sign(
expiresIn: "15m", // Short-lived access token { id: user.id, jwtVersion: user.jwtVersion },
}); process.env.JWT_SECRET,
{ expiresIn: "15m" } // Short-lived access token
);
const refreshToken = jwt.sign( const refreshToken = jwt.sign(
{ id: user.id, type: "refresh" }, { id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_SECRET, process.env.JWT_SECRET,
{ expiresIn: "7d" } { expiresIn: "7d" }
); );
@@ -206,7 +220,7 @@ router.post(
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("User login successful", { reqLogger.info("User login successful", {
userId: user.id, userId: user.id,
email: user.email email: user.email,
}); });
res.json({ res.json({
@@ -225,7 +239,7 @@ router.post(
reqLogger.error("Login error", { reqLogger.error("Login error", {
error: error.message, error: error.message,
stack: error.stack, stack: error.stack,
email: req.body.email email: req.body.email,
}); });
res.status(500).json({ error: "Login failed. Please try again." }); res.status(500).json({ error: "Login failed. Please try again." });
} }
@@ -243,13 +257,17 @@ router.post(
const { code } = req.body; const { code } = req.body;
if (!code) { 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 // Exchange authorization code for tokens
const { tokens } = await googleClient.getToken({ const { tokens } = await googleClient.getToken({
code, 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 // Verify the ID token from the token response
@@ -303,12 +321,14 @@ router.post(
} }
// Generate JWT tokens // Generate JWT tokens
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { const token = jwt.sign(
expiresIn: "15m", { id: user.id, jwtVersion: user.jwtVersion },
}); process.env.JWT_SECRET,
{ expiresIn: "15m" }
);
const refreshToken = jwt.sign( const refreshToken = jwt.sign(
{ id: user.id, type: "refresh" }, { id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_SECRET, process.env.JWT_SECRET,
{ expiresIn: "7d" } { expiresIn: "7d" }
); );
@@ -334,7 +354,9 @@ router.post(
reqLogger.info("Google authentication successful", { reqLogger.info("Google authentication successful", {
userId: user.id, userId: user.id,
email: user.email, 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({ res.json({
@@ -351,9 +373,9 @@ router.post(
}); });
} catch (error) { } catch (error) {
if (error.message && error.message.includes("invalid_grant")) { if (error.message && error.message.includes("invalid_grant")) {
return res return res.status(401).json({
.status(401) error: "Invalid or expired authorization code. Please try again.",
.json({ error: "Invalid or expired authorization code. Please try again." }); });
} }
if (error.message && error.message.includes("redirect_uri_mismatch")) { if (error.message && error.message.includes("redirect_uri_mismatch")) {
return res return res
@@ -364,7 +386,7 @@ router.post(
reqLogger.error("Google OAuth error", { reqLogger.error("Google OAuth error", {
error: error.message, error: error.message,
stack: error.stack, stack: error.stack,
codePresent: !!req.body.code codePresent: !!req.body.code,
}); });
res res
.status(500) .status(500)
@@ -546,10 +568,20 @@ router.post("/refresh", async (req, res) => {
return res.status(401).json({ error: "User not found" }); 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 // Generate new access token
const newAccessToken = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { const newAccessToken = jwt.sign(
expiresIn: "15m", { id: user.id, jwtVersion: user.jwtVersion },
}); process.env.JWT_SECRET,
{ expiresIn: "15m" }
);
// Set new access token cookie // Set new access token cookie
res.cookie("accessToken", newAccessToken, { res.cookie("accessToken", newAccessToken, {
@@ -561,7 +593,7 @@ router.post("/refresh", async (req, res) => {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Token refresh successful", { reqLogger.info("Token refresh successful", {
userId: user.id userId: user.id,
}); });
res.json({ res.json({
@@ -579,7 +611,7 @@ router.post("/refresh", async (req, res) => {
reqLogger.error("Token refresh error", { reqLogger.error("Token refresh error", {
error: error.message, error: error.message,
stack: error.stack, stack: error.stack,
userId: req.user?.id userId: req.user?.id,
}); });
res.status(401).json({ error: "Invalid or expired refresh token" }); 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) => { router.post("/logout", (req, res) => {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("User logout", { reqLogger.info("User logout", {
userId: req.user?.id || 'anonymous' userId: req.user?.id || "anonymous",
}); });
// Clear cookies // Clear cookies
@@ -604,13 +636,221 @@ router.get("/status", optionalAuth, async (req, res) => {
if (req.user) { if (req.user) {
res.json({ res.json({
authenticated: true, authenticated: true,
user: req.user user: req.user,
}); });
} else { } else {
res.json({ 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; module.exports = router;

View File

@@ -36,6 +36,8 @@ class EmailService {
"conditionCheckReminder.html", "conditionCheckReminder.html",
"rentalConfirmation.html", "rentalConfirmation.html",
"emailVerification.html", "emailVerification.html",
"passwordReset.html",
"passwordChanged.html",
"lateReturnCS.html", "lateReturnCS.html",
"damageReportCS.html", "damageReportCS.html",
"lostItemCS.html", "lostItemCS.html",
@@ -244,6 +246,31 @@ class EmailService {
<p><strong>This link will expire in 24 hours.</strong></p> <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 ( 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 = {}) { async sendTemplateEmail(toEmail, subject, templateName, variables = {}) {
const htmlContent = this.renderTemplate(templateName, variables); const htmlContent = this.renderTemplate(templateName, variables);
return await this.sendEmail(toEmail, subject, htmlContent); 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);
});
});
});

View File

@@ -7,6 +7,7 @@ import AuthModal from './components/AuthModal';
import Home from './pages/Home'; import Home from './pages/Home';
import GoogleCallback from './pages/GoogleCallback'; import GoogleCallback from './pages/GoogleCallback';
import VerifyEmail from './pages/VerifyEmail'; import VerifyEmail from './pages/VerifyEmail';
import ResetPassword from './pages/ResetPassword';
import ItemList from './pages/ItemList'; import ItemList from './pages/ItemList';
import ItemDetail from './pages/ItemDetail'; import ItemDetail from './pages/ItemDetail';
import EditItem from './pages/EditItem'; import EditItem from './pages/EditItem';
@@ -39,6 +40,7 @@ const AppContent: React.FC = () => {
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/auth/google/callback" element={<GoogleCallback />} /> <Route path="/auth/google/callback" element={<GoogleCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} /> <Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/items" element={<ItemList />} /> <Route path="/items" element={<ItemList />} />
<Route path="/items/:id" element={<ItemDetail />} /> <Route path="/items/:id" element={<ItemDetail />} />
<Route path="/users/:id" element={<PublicProfile />} /> <Route path="/users/:id" element={<PublicProfile />} />

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import PasswordStrengthMeter from "./PasswordStrengthMeter"; import PasswordStrengthMeter from "./PasswordStrengthMeter";
import PasswordInput from "./PasswordInput"; import PasswordInput from "./PasswordInput";
import ForgotPasswordModal from "./ForgotPasswordModal";
interface AuthModalProps { interface AuthModalProps {
show: boolean; show: boolean;
@@ -21,6 +22,7 @@ const AuthModal: React.FC<AuthModalProps> = ({
const [lastName, setLastName] = useState(""); const [lastName, setLastName] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [showForgotPassword, setShowForgotPassword] = useState(false);
const { login, register } = useAuth(); const { login, register } = useAuth();
@@ -83,17 +85,18 @@ const AuthModal: React.FC<AuthModalProps> = ({
}; };
if (!show) return null; if (!show && !showForgotPassword) return null;
return ( return (
<> <>
<div {!showForgotPassword && (
className="modal show d-block" <div
tabIndex={-1} className="modal show d-block"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }} tabIndex={-1}
> style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
<div className="modal-dialog modal-dialog-centered"> >
<div className="modal-content"> <div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header border-0 pb-0"> <div className="modal-header border-0 pb-0">
<button <button
type="button" type="button"
@@ -168,6 +171,21 @@ const AuthModal: React.FC<AuthModalProps> = ({
</div> </div>
)} )}
{mode === "login" && (
<div className="text-end mb-3" style={{ marginTop: '-0.5rem' }}>
<a
href="#"
className="text-primary text-decoration-none small"
onClick={(e) => {
e.preventDefault();
setShowForgotPassword(true);
}}
>
Forgot password?
</a>
</div>
)}
<button <button
type="submit" type="submit"
className="btn btn-primary w-100 py-3 mb-1" className="btn btn-primary w-100 py-3 mb-1"
@@ -230,7 +248,15 @@ const AuthModal: React.FC<AuthModalProps> = ({
</div> </div>
</div> </div>
</div> </div>
</div> </div>
)}
{/* Forgot Password Modal */}
<ForgotPasswordModal
show={showForgotPassword}
onHide={() => setShowForgotPassword(false)}
onBackToLogin={() => setShowForgotPassword(false)}
/>
</> </>
); );
}; };

View File

@@ -1,135 +0,0 @@
import React, { useState, useEffect } from "react";
interface BetaPasswordProtectionProps {
children: React.ReactNode;
}
const BetaPasswordProtection: React.FC<BetaPasswordProtectionProps> = ({
children,
}) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check if user already has valid beta access
const betaToken = localStorage.getItem("betaAccess");
if (betaToken) {
// Verify the stored token is still valid
verifyBetaAccess(betaToken);
} else {
setLoading(false);
}
}, []);
const verifyBetaAccess = async (token: string) => {
try {
const response = await fetch(
`${process.env.REACT_APP_API_URL}/beta/verify`,
{
headers: {
"X-Beta-Password": token,
},
}
);
if (response.ok) {
setIsAuthenticated(true);
} else {
localStorage.removeItem("betaAccess");
}
} catch (error) {
localStorage.removeItem("betaAccess");
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!password) {
setError("Please enter a password");
return;
}
try {
const response = await fetch(
`${process.env.REACT_APP_API_URL}/beta/verify`,
{
headers: {
"X-Beta-Password": password,
},
}
);
if (response.ok) {
localStorage.setItem("betaAccess", password);
setIsAuthenticated(true);
} else {
setError("Invalid beta password");
}
} catch (error) {
setError("Failed to verify beta password");
}
};
if (loading) {
return (
<div className="min-vh-100 d-flex align-items-center justify-content-center">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
);
}
if (!isAuthenticated) {
return (
<div className="min-vh-100 d-flex align-items-center justify-content-center bg-light">
<div
className="card shadow"
style={{ maxWidth: "400px", width: "100%" }}
>
<div className="card-body p-5">
<h2 className="text-center mb-4">Beta Access Required</h2>
<p className="text-muted text-center mb-4">
This site is currently in beta testing. Please enter the beta
password to continue.
</p>
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label htmlFor="betaPassword" className="form-label">
Beta Password
</label>
<input
type="password"
className="form-control"
id="betaPassword"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter beta password"
autoFocus
/>
</div>
{error && (
<div className="alert alert-danger" role="alert">
{error}
</div>
)}
<button type="submit" className="btn btn-primary w-100">
Access Beta
</button>
</form>
</div>
</div>
</div>
);
}
return <>{children}</>;
};
export default BetaPasswordProtection;

View File

@@ -0,0 +1,175 @@
import React, { useState } from "react";
import { authAPI } from "../services/api";
interface ForgotPasswordModalProps {
show: boolean;
onHide: () => void;
onBackToLogin?: () => void;
}
const ForgotPasswordModal: React.FC<ForgotPasswordModalProps> = ({
show,
onHide,
onBackToLogin,
}) => {
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const resetModal = () => {
setEmail("");
setError("");
setSuccess(false);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
try {
await authAPI.forgotPassword(email);
setSuccess(true);
} catch (err: any) {
console.error('Forgot password error:', err);
const errorData = err.response?.data;
// Check for rate limiting
if (err.response?.status === 429) {
const retryAfter = errorData?.retryAfter;
if (retryAfter) {
const minutes = Math.ceil(retryAfter / 60);
setError(`Too many password reset requests. Please try again in ${minutes} minute${minutes > 1 ? 's' : ''}.`);
} else {
setError('Too many password reset requests. Please try again later.');
}
} else if (errorData?.details) {
// Validation errors
const validationErrors = errorData.details.map((d: any) => d.message).join(', ');
setError(validationErrors);
} else {
setError(errorData?.error || "Failed to send reset email. Please try again.");
}
} finally {
setLoading(false);
}
};
if (!show) return null;
return (
<>
<div
className="modal show d-block"
tabIndex={-1}
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header border-0 pb-0">
<button
type="button"
className="btn-close"
onClick={() => {
resetModal();
onHide();
}}
></button>
</div>
<div className="modal-body px-4 pb-4">
{success ? (
<>
<div className="text-center">
<i
className="bi bi-envelope-check text-success"
style={{ fontSize: "3rem" }}
></i>
<h4 className="mt-3">Check Your Email</h4>
<p className="text-muted">
If an account exists with that email address, you will
receive password reset instructions shortly.
</p>
<p className="text-muted small">
Please check your spam folder if you don't see the email
within a few minutes.
</p>
<button
type="button"
className="btn btn-primary mt-3"
onClick={() => {
resetModal();
onHide();
}}
>
Close
</button>
</div>
</>
) : (
<>
<h4 className="text-center mb-2">Forgot Password?</h4>
<p className="text-center text-muted mb-4">
Enter your email address and we'll send you instructions to
reset your password.
</p>
{error && (
<div className="alert alert-danger" role="alert">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label className="form-label">Email Address</label>
<input
type="email"
className="form-control"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required
/>
</div>
<button
type="submit"
className="btn btn-primary w-100 py-3"
disabled={loading || !email}
>
{loading ? "Sending..." : "Send Reset Instructions"}
</button>
</form>
<div className="text-center mt-3">
<small className="text-muted">
Remember your password?{" "}
<a
href="#"
className="text-primary text-decoration-none"
onClick={(e) => {
e.preventDefault();
resetModal();
if (onBackToLogin) {
onBackToLogin();
} else {
onHide();
}
}}
>
Back to Login
</a>
</small>
</div>
</>
)}
</div>
</div>
</div>
</div>
</>
);
};
export default ForgotPasswordModal;

View File

@@ -0,0 +1,205 @@
import React, { useEffect, useState, useRef } from 'react';
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { authAPI } from '../services/api';
import PasswordInput from '../components/PasswordInput';
import PasswordStrengthMeter from '../components/PasswordStrengthMeter';
const ResetPassword: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { openAuthModal } = useAuth();
const [error, setError] = useState<string>('');
const [success, setSuccess] = useState(false);
const [validating, setValidating] = useState(true);
const [tokenValid, setTokenValid] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const hasValidated = useRef(false);
useEffect(() => {
const validateToken = async () => {
// Prevent double execution in React StrictMode
if (hasValidated.current) {
return;
}
hasValidated.current = true;
try {
const token = searchParams.get('token');
if (!token) {
setError('No reset token provided.');
setValidating(false);
return;
}
// Verify the token is valid
await authAPI.verifyResetToken(token);
setTokenValid(true);
setValidating(false);
} catch (err: any) {
console.error('Token validation error:', err);
const errorData = err.response?.data;
if (errorData?.code === 'TOKEN_EXPIRED') {
setError('Your password reset link has expired. Please request a new one.');
} else if (errorData?.code === 'TOKEN_INVALID') {
setError('Invalid password reset link. The link may have already been used or is incorrect.');
} else {
setError(errorData?.error || 'Failed to validate reset link. Please try again.');
}
setValidating(false);
setTokenValid(false);
}
};
validateToken();
}, [searchParams]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// Validate passwords match
if (newPassword !== confirmPassword) {
setError('Passwords do not match.');
return;
}
setSubmitting(true);
try {
const token = searchParams.get('token');
if (!token) {
setError('No reset token provided.');
setSubmitting(false);
return;
}
await authAPI.resetPassword(token, newPassword);
setSuccess(true);
} catch (err: any) {
console.error('Password reset error:', err);
const errorData = err.response?.data;
if (errorData?.code === 'TOKEN_EXPIRED') {
setError('Your reset link has expired. Please request a new one.');
} else if (errorData?.code === 'TOKEN_INVALID') {
setError('Invalid reset link. Please request a new one.');
} else if (errorData?.details) {
// Validation errors
const validationErrors = errorData.details.map((d: any) => d.message).join(', ');
setError(validationErrors);
} else {
setError(errorData?.error || 'Failed to reset password. Please try again.');
}
setSubmitting(false);
}
};
return (
<div className="container">
<div className="row justify-content-center mt-5">
<div className="col-md-6">
<div className="card">
<div className="card-body py-5">
{validating ? (
<div className="text-center">
<div className="spinner-border text-primary mb-3" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<h5>Verifying Reset Link...</h5>
<p className="text-muted">Please wait while we verify your password reset link.</p>
</div>
) : success ? (
<div className="text-center">
<i className="bi bi-check-circle text-success" style={{ fontSize: '3rem' }}></i>
<h5 className="mt-3">Password Reset Successfully!</h5>
<p className="text-muted">
Your password has been reset. You can now log in with your new password.
</p>
<button
className="btn btn-primary mt-3"
onClick={() => {
navigate('/', { replace: true });
openAuthModal('login');
}}
>
Log In Now
</button>
</div>
) : !tokenValid ? (
<div className="text-center">
<i className="bi bi-exclamation-circle text-danger" style={{ fontSize: '3rem' }}></i>
<h5 className="mt-3">Invalid Reset Link</h5>
<p className="text-danger">{error}</p>
<div className="mt-3">
<Link to="/" className="btn btn-outline-secondary">
Return to Home
</Link>
</div>
</div>
) : (
<>
<div className="text-center mb-4">
<h4>Reset Your Password</h4>
<p className="text-muted">Enter your new password below.</p>
</div>
{error && (
<div className="alert alert-danger" role="alert">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<PasswordInput
id="newPassword"
label="New Password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
/>
<div style={{ marginTop: '-0.75rem', marginBottom: '1rem' }}>
<PasswordStrengthMeter password={newPassword} />
</div>
<PasswordInput
id="confirmPassword"
label="Confirm New Password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
<button
type="submit"
className="btn btn-primary w-100 py-3 mt-3"
disabled={submitting || !newPassword || !confirmPassword}
>
{submitting ? 'Resetting Password...' : 'Reset Password'}
</button>
</form>
<div className="text-center mt-3">
<Link to="/" className="text-decoration-none">
Return to Home
</Link>
</div>
</>
)}
</div>
</div>
</div>
</div>
</div>
);
};
export default ResetPassword;

View File

@@ -168,6 +168,9 @@ export const authAPI = {
getStatus: () => api.get("/auth/status"), getStatus: () => api.get("/auth/status"),
verifyEmail: (token: string) => api.post("/auth/verify-email", { token }), verifyEmail: (token: string) => api.post("/auth/verify-email", { token }),
resendVerification: () => api.post("/auth/resend-verification"), resendVerification: () => api.post("/auth/resend-verification"),
forgotPassword: (email: string) => api.post("/auth/forgot-password", { email }),
verifyResetToken: (token: string) => api.post("/auth/verify-reset-token", { token }),
resetPassword: (token: string, newPassword: string) => api.post("/auth/reset-password", { token, newPassword }),
}; };
export const userAPI = { export const userAPI = {