diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js
index b0f9d4e..fa61c85 100644
--- a/backend/middleware/auth.js
+++ b/backend/middleware/auth.js
@@ -94,4 +94,23 @@ const optionalAuth = async (req, res, next) => {
}
};
-module.exports = { authenticateToken, optionalAuth };
+// Require verified email middleware - must be used after authenticateToken
+const requireVerifiedEmail = (req, res, next) => {
+ if (!req.user) {
+ return res.status(401).json({
+ error: "Authentication required",
+ code: "NO_AUTH",
+ });
+ }
+
+ if (!req.user.isVerified) {
+ return res.status(403).json({
+ error: "Email verification required. Please verify your email address to perform this action.",
+ code: "EMAIL_NOT_VERIFIED",
+ });
+ }
+
+ next();
+};
+
+module.exports = { authenticateToken, optionalAuth, requireVerifiedEmail };
diff --git a/backend/middleware/validation.js b/backend/middleware/validation.js
index 447beeb..05d6855 100644
--- a/backend/middleware/validation.js
+++ b/backend/middleware/validation.js
@@ -8,7 +8,7 @@ const purify = DOMPurify(window);
// Password strength validation
const passwordStrengthRegex =
- /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).{8,}$/;
+ /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z])(?=.*[-@$!%*?^]).{8,}$/; //-@$!%*?^
const commonPasswords = [
"password",
"123456",
diff --git a/backend/models/User.js b/backend/models/User.js
index a3d137f..a6eafcc 100644
--- a/backend/models/User.js
+++ b/backend/models/User.js
@@ -72,6 +72,18 @@ const User = sequelize.define(
type: DataTypes.BOOLEAN,
defaultValue: false,
},
+ verificationToken: {
+ type: DataTypes.STRING,
+ allowNull: true,
+ },
+ verificationTokenExpiry: {
+ type: DataTypes.DATE,
+ allowNull: true,
+ },
+ verifiedAt: {
+ type: DataTypes.DATE,
+ allowNull: true,
+ },
defaultAvailableAfter: {
type: DataTypes.STRING,
defaultValue: "09:00",
@@ -173,4 +185,41 @@ User.prototype.resetLoginAttempts = async function () {
});
};
+// Email verification methods
+User.prototype.generateVerificationToken = async function () {
+ const crypto = require("crypto");
+ const token = crypto.randomBytes(32).toString("hex");
+ const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
+
+ return this.update({
+ verificationToken: token,
+ verificationTokenExpiry: expiry,
+ });
+};
+
+User.prototype.isVerificationTokenValid = function (token) {
+ if (!this.verificationToken || !this.verificationTokenExpiry) {
+ return false;
+ }
+
+ if (this.verificationToken !== token) {
+ return false;
+ }
+
+ if (new Date() > new Date(this.verificationTokenExpiry)) {
+ return false;
+ }
+
+ return true;
+};
+
+User.prototype.verifyEmail = async function () {
+ return this.update({
+ isVerified: true,
+ verifiedAt: new Date(),
+ verificationToken: null,
+ verificationTokenExpiry: null,
+ });
+};
+
module.exports = User;
diff --git a/backend/routes/auth.js b/backend/routes/auth.js
index 7bb7142..8b7773c 100644
--- a/backend/routes/auth.js
+++ b/backend/routes/auth.js
@@ -3,6 +3,7 @@ const jwt = require("jsonwebtoken");
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 {
sanitizeInput,
validateRegistration,
@@ -62,6 +63,24 @@ router.post(
phone,
});
+ // Generate verification token and send email
+ await user.generateVerificationToken();
+
+ // Send verification email (don't block registration if email fails)
+ let verificationEmailSent = false;
+ try {
+ await emailService.sendVerificationEmail(user, user.verificationToken);
+ verificationEmailSent = true;
+ } catch (emailError) {
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.error("Failed to send verification email", {
+ error: emailError.message,
+ userId: user.id,
+ 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
});
@@ -103,7 +122,9 @@ router.post(
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
+ isVerified: user.isVerified,
},
+ verificationEmailSent,
// Don't send token in response body for security
});
} catch (error) {
@@ -195,6 +216,7 @@ router.post(
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
+ isVerified: user.isVerified,
},
// Don't send token in response body for security
});
@@ -266,7 +288,7 @@ router.post(
});
}
- // Create new user
+ // Create new user (Google OAuth users are auto-verified)
user = await User.create({
email,
firstName,
@@ -275,6 +297,8 @@ router.post(
providerId: googleId,
profileImage: picture,
username: email.split("@")[0] + "_" + googleId.slice(-6), // Generate unique username
+ isVerified: true,
+ verifiedAt: new Date(),
});
}
@@ -321,6 +345,7 @@ router.post(
firstName: user.firstName,
lastName: user.lastName,
profileImage: user.profileImage,
+ isVerified: user.isVerified,
},
// Don't send token in response body for security
});
@@ -348,6 +373,157 @@ router.post(
}
);
+// Email verification endpoint
+router.post("/verify-email", sanitizeInput, async (req, res) => {
+ try {
+ const { token } = req.body;
+
+ if (!token) {
+ return res.status(400).json({
+ error: "Verification token required",
+ code: "TOKEN_REQUIRED",
+ });
+ }
+
+ // Find user with this verification token
+ const user = await User.findOne({
+ where: { verificationToken: token },
+ });
+
+ if (!user) {
+ return res.status(400).json({
+ error: "Invalid verification token",
+ code: "VERIFICATION_TOKEN_INVALID",
+ });
+ }
+
+ // Check if already verified
+ if (user.isVerified) {
+ return res.status(400).json({
+ error: "Email already verified",
+ code: "ALREADY_VERIFIED",
+ });
+ }
+
+ // Check if token is valid (not expired)
+ if (!user.isVerificationTokenValid(token)) {
+ return res.status(400).json({
+ error: "Verification token has expired. Please request a new one.",
+ code: "VERIFICATION_TOKEN_EXPIRED",
+ });
+ }
+
+ // Verify the email
+ await user.verifyEmail();
+
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.info("Email verified successfully", {
+ userId: user.id,
+ email: user.email,
+ });
+
+ res.json({
+ message: "Email verified successfully",
+ user: {
+ id: user.id,
+ email: user.email,
+ isVerified: true,
+ },
+ });
+ } catch (error) {
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.error("Email verification error", {
+ error: error.message,
+ stack: error.stack,
+ });
+ res.status(500).json({
+ error: "Email verification failed. Please try again.",
+ });
+ }
+});
+
+// Resend verification email endpoint
+router.post(
+ "/resend-verification",
+ loginLimiter, // Use login limiter for rate limiting (max 3 per hour)
+ sanitizeInput,
+ async (req, res) => {
+ try {
+ // Get user from cookies
+ const { accessToken } = req.cookies;
+
+ if (!accessToken) {
+ return res.status(401).json({
+ error: "Authentication required",
+ code: "NO_TOKEN",
+ });
+ }
+
+ const decoded = jwt.verify(accessToken, process.env.JWT_SECRET);
+ const user = await User.findByPk(decoded.id);
+
+ if (!user) {
+ return res.status(404).json({
+ error: "User not found",
+ code: "USER_NOT_FOUND",
+ });
+ }
+
+ // Check if already verified
+ if (user.isVerified) {
+ return res.status(400).json({
+ error: "Email already verified",
+ code: "ALREADY_VERIFIED",
+ });
+ }
+
+ // Generate new verification token
+ await user.generateVerificationToken();
+
+ // Send verification email
+ try {
+ await emailService.sendVerificationEmail(user, user.verificationToken);
+ } catch (emailError) {
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.error("Failed to resend verification email", {
+ error: emailError.message,
+ userId: user.id,
+ email: user.email,
+ });
+ return res.status(500).json({
+ error: "Failed to send verification email. Please try again.",
+ });
+ }
+
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.info("Verification email resent", {
+ userId: user.id,
+ email: user.email,
+ });
+
+ res.json({
+ message: "Verification email sent successfully",
+ });
+ } catch (error) {
+ if (error.name === "TokenExpiredError") {
+ return res.status(401).json({
+ error: "Session expired. Please log in again.",
+ code: "TOKEN_EXPIRED",
+ });
+ }
+
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.error("Resend verification error", {
+ error: error.message,
+ stack: error.stack,
+ });
+ res.status(500).json({
+ error: "Failed to resend verification email. Please try again.",
+ });
+ }
+ }
+);
+
// Refresh token endpoint
router.post("/refresh", async (req, res) => {
try {
@@ -395,6 +571,7 @@ router.post("/refresh", async (req, res) => {
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
+ isVerified: user.isVerified,
},
});
} catch (error) {
diff --git a/backend/routes/items.js b/backend/routes/items.js
index 6fe8fd5..615dceb 100644
--- a/backend/routes/items.js
+++ b/backend/routes/items.js
@@ -1,7 +1,7 @@
const express = require("express");
const { Op } = require("sequelize");
const { Item, User, Rental } = require("../models"); // Import from models/index.js to get models with associations
-const { authenticateToken } = require("../middleware/auth");
+const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth");
const logger = require("../utils/logger");
const router = express.Router();
@@ -213,7 +213,7 @@ router.get("/:id", async (req, res) => {
}
});
-router.post("/", authenticateToken, async (req, res) => {
+router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
try {
const item = await Item.create({
...req.body,
diff --git a/backend/routes/rentals.js b/backend/routes/rentals.js
index 199cb55..c67f39b 100644
--- a/backend/routes/rentals.js
+++ b/backend/routes/rentals.js
@@ -1,7 +1,7 @@
const express = require("express");
const { Op } = require("sequelize");
const { Rental, Item, User } = require("../models"); // Import from models/index.js to get models with associations
-const { authenticateToken } = require("../middleware/auth");
+const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth");
const FeeCalculator = require("../utils/feeCalculator");
const RefundService = require("../services/refundService");
const LateReturnService = require("../services/lateReturnService");
@@ -152,7 +152,7 @@ router.get("/:id", authenticateToken, async (req, res) => {
}
});
-router.post("/", authenticateToken, async (req, res) => {
+router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
try {
const {
itemId,
diff --git a/backend/routes/stripe.js b/backend/routes/stripe.js
index 320d9a8..36b32ca 100644
--- a/backend/routes/stripe.js
+++ b/backend/routes/stripe.js
@@ -1,5 +1,5 @@
const express = require("express");
-const { authenticateToken } = require("../middleware/auth");
+const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth");
const { User, Item } = require("../models");
const StripeService = require("../services/stripeService");
const logger = require("../utils/logger");
@@ -39,7 +39,7 @@ router.get("/checkout-session/:sessionId", async (req, res) => {
});
// Create connected account
-router.post("/accounts", authenticateToken, async (req, res) => {
+router.post("/accounts", authenticateToken, requireVerifiedEmail, async (req, res) => {
try {
const user = await User.findByPk(req.user.id);
@@ -87,7 +87,7 @@ router.post("/accounts", authenticateToken, async (req, res) => {
});
// Generate onboarding link
-router.post("/account-links", authenticateToken, async (req, res) => {
+router.post("/account-links", authenticateToken, requireVerifiedEmail, async (req, res) => {
try {
const user = await User.findByPk(req.user.id);
@@ -176,6 +176,7 @@ router.get("/account-status", authenticateToken, async (req, res) => {
router.post(
"/create-setup-checkout-session",
authenticateToken,
+ requireVerifiedEmail,
async (req, res) => {
try {
const { rentalData } = req.body;
diff --git a/backend/services/emailService.js b/backend/services/emailService.js
index f66fa91..45d3fe1 100644
--- a/backend/services/emailService.js
+++ b/backend/services/emailService.js
@@ -35,6 +35,7 @@ class EmailService {
const templateFiles = [
"conditionCheckReminder.html",
"rentalConfirmation.html",
+ "emailVerification.html",
"lateReturnCS.html",
"damageReportCS.html",
"lostItemCS.html",
@@ -231,6 +232,18 @@ class EmailService {
Thank you for using RentAll!
`
),
+
+ emailVerification: baseTemplate.replace(
+ "{{content}}",
+ `
+ Hi {{recipientName}},
+ Verify Your Email Address
+ Thank you for registering with RentAll! Please verify your email address by clicking the button below.
+ Verify Email Address
+ If the button doesn't work, copy and paste this link into your browser: {{verificationUrl}}
+ This link will expire in 24 hours.
+ `
+ ),
};
return (
@@ -295,6 +308,24 @@ class EmailService {
);
}
+ async sendVerificationEmail(user, verificationToken) {
+ const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
+ const verificationUrl = `${frontendUrl}/verify-email?token=${verificationToken}`;
+
+ const variables = {
+ recipientName: user.firstName || "there",
+ verificationUrl: verificationUrl,
+ };
+
+ const htmlContent = this.renderTemplate("emailVerification", variables);
+
+ return await this.sendEmail(
+ user.email,
+ "Verify Your Email - RentAll",
+ htmlContent
+ );
+ }
+
async sendTemplateEmail(toEmail, subject, templateName, variables = {}) {
const htmlContent = this.renderTemplate(templateName, variables);
return await this.sendEmail(toEmail, subject, htmlContent);
diff --git a/backend/templates/emails/emailVerification.html b/backend/templates/emails/emailVerification.html
new file mode 100644
index 0000000..797d09b
--- /dev/null
+++ b/backend/templates/emails/emailVerification.html
@@ -0,0 +1,229 @@
+
+
+
+
+
+
+ Verify Your Email - RentAll
+
+
+
+
+
+
+
+
Hi {{recipientName}},
+
+
Verify Your Email Address
+
+
Thank you for registering with RentAll! To complete your account setup and start renting items, please verify your email address by clicking the button below.
+
+
+
+
+
Why verify? Email verification helps us ensure account security and allows you to create listings, make rentals, and process payments.
+
+
+
If the button doesn't work, you can copy and paste this link into your browser:
+
{{verificationUrl}}
+
+
+
This link will expire in 24 hours. If you need a new verification link, you can request one from your account settings.
+
+
+
Didn't create an account? If you didn't register for a RentAll account, you can safely ignore this email.
+
+
Welcome to the RentAll community!
+
+
+
+
+
+
diff --git a/backend/tests/unit/middleware/auth.test.js b/backend/tests/unit/middleware/auth.test.js
index a2120e9..31022ab 100644
--- a/backend/tests/unit/middleware/auth.test.js
+++ b/backend/tests/unit/middleware/auth.test.js
@@ -1,4 +1,4 @@
-const { authenticateToken } = require('../../../middleware/auth');
+const { authenticateToken, requireVerifiedEmail } = require('../../../middleware/auth');
const jwt = require('jsonwebtoken');
jest.mock('jsonwebtoken');
@@ -191,4 +191,161 @@ describe('Auth Middleware', () => {
});
});
});
+});
+
+describe('requireVerifiedEmail Middleware', () => {
+ let req, res, next;
+
+ beforeEach(() => {
+ req = {
+ user: null
+ };
+ res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn()
+ };
+ next = jest.fn();
+ jest.clearAllMocks();
+ });
+
+ describe('Verified users', () => {
+ it('should call next for verified user', () => {
+ req.user = {
+ id: 1,
+ email: 'verified@test.com',
+ isVerified: true
+ };
+
+ requireVerifiedEmail(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ expect(res.json).not.toHaveBeenCalled();
+ });
+
+ it('should call next for verified OAuth user', () => {
+ req.user = {
+ id: 2,
+ email: 'google@test.com',
+ authProvider: 'google',
+ isVerified: true
+ };
+
+ requireVerifiedEmail(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Unverified users', () => {
+ it('should return 403 for unverified user', () => {
+ req.user = {
+ id: 1,
+ email: 'unverified@test.com',
+ isVerified: false
+ };
+
+ requireVerifiedEmail(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Email verification required. Please verify your email address to perform this action.',
+ code: 'EMAIL_NOT_VERIFIED'
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should return 403 when isVerified is null', () => {
+ req.user = {
+ id: 1,
+ email: 'test@test.com',
+ isVerified: null
+ };
+
+ requireVerifiedEmail(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Email verification required. Please verify your email address to perform this action.',
+ code: 'EMAIL_NOT_VERIFIED'
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should return 403 when isVerified is undefined', () => {
+ req.user = {
+ id: 1,
+ email: 'test@test.com'
+ // isVerified is undefined
+ };
+
+ requireVerifiedEmail(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Email verification required. Please verify your email address to perform this action.',
+ code: 'EMAIL_NOT_VERIFIED'
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('No user', () => {
+ it('should return 401 when user is not set', () => {
+ req.user = null;
+
+ requireVerifiedEmail(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Authentication required',
+ code: 'NO_AUTH'
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should return 401 when user is undefined', () => {
+ req.user = undefined;
+
+ requireVerifiedEmail(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Authentication required',
+ code: 'NO_AUTH'
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Edge cases', () => {
+ it('should handle user object with extra fields', () => {
+ req.user = {
+ id: 1,
+ email: 'test@test.com',
+ isVerified: true,
+ firstName: 'Test',
+ lastName: 'User',
+ phone: '1234567890'
+ };
+
+ requireVerifiedEmail(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ });
+
+ it('should prioritize missing user over unverified user', () => {
+ // If called without authenticateToken first
+ req.user = null;
+
+ requireVerifiedEmail(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Authentication required',
+ code: 'NO_AUTH'
+ });
+ });
+ });
});
\ No newline at end of file
diff --git a/backend/tests/unit/routes/auth.test.js b/backend/tests/unit/routes/auth.test.js
index c325450..13e4576 100644
--- a/backend/tests/unit/routes/auth.test.js
+++ b/backend/tests/unit/routes/auth.test.js
@@ -38,11 +38,17 @@ jest.mock('../../../middleware/rateLimiter', () => ({
registerLimiter: (req, res, next) => next(),
}));
+jest.mock('../../../services/emailService', () => ({
+ sendVerificationEmail: jest.fn()
+}));
+
const { User } = require('../../../models');
+const emailService = require('../../../services/emailService');
// Set up OAuth2Client mock before requiring authRoutes
const mockGoogleClient = {
- verifyIdToken: jest.fn()
+ verifyIdToken: jest.fn(),
+ getToken: jest.fn()
};
OAuth2Client.mockImplementation(() => mockGoogleClient);
@@ -90,10 +96,13 @@ describe('Auth Routes', () => {
username: 'testuser',
email: 'test@example.com',
firstName: 'Test',
- lastName: 'User'
+ lastName: 'User',
+ isVerified: false,
+ generateVerificationToken: jest.fn().mockResolvedValue()
};
User.create.mockResolvedValue(newUser);
+ emailService.sendVerificationEmail.mockResolvedValue();
const response = await request(app)
.post('/auth/register')
@@ -112,8 +121,12 @@ describe('Auth Routes', () => {
username: 'testuser',
email: 'test@example.com',
firstName: 'Test',
- lastName: 'User'
+ lastName: 'User',
+ isVerified: false
});
+ expect(response.body.verificationEmailSent).toBe(true);
+ expect(newUser.generateVerificationToken).toHaveBeenCalled();
+ expect(emailService.sendVerificationEmail).toHaveBeenCalledWith(newUser, newUser.verificationToken);
// Check that cookies are set
expect(response.headers['set-cookie']).toEqual(
@@ -571,8 +584,17 @@ describe('Auth Routes', () => {
process.env.NODE_ENV = 'prod';
User.findOne.mockResolvedValue(null);
- const newUser = { id: 1, username: 'test', email: 'test@example.com', firstName: 'Test', lastName: 'User' };
+ const newUser = {
+ id: 1,
+ username: 'test',
+ email: 'test@example.com',
+ firstName: 'Test',
+ lastName: 'User',
+ isVerified: false,
+ generateVerificationToken: jest.fn().mockResolvedValue()
+ };
User.create.mockResolvedValue(newUser);
+ emailService.sendVerificationEmail.mockResolvedValue();
jwt.sign.mockReturnValueOnce('access').mockReturnValueOnce('refresh');
const response = await request(app)
@@ -629,7 +651,17 @@ describe('Auth Routes', () => {
describe('Token management', () => {
it('should generate both access and refresh tokens on registration', async () => {
User.findOne.mockResolvedValue(null);
- User.create.mockResolvedValue({ id: 1, username: 'test', email: 'test@example.com', firstName: 'Test', lastName: 'User' });
+ const newUser = {
+ id: 1,
+ username: 'test',
+ email: 'test@example.com',
+ firstName: 'Test',
+ lastName: 'User',
+ isVerified: false,
+ generateVerificationToken: jest.fn().mockResolvedValue()
+ };
+ User.create.mockResolvedValue(newUser);
+ emailService.sendVerificationEmail.mockResolvedValue();
jwt.sign
.mockReturnValueOnce('access-token')
@@ -659,7 +691,17 @@ describe('Auth Routes', () => {
it('should set correct cookie options', async () => {
User.findOne.mockResolvedValue(null);
- User.create.mockResolvedValue({ id: 1, username: 'test', email: 'test@example.com', firstName: 'Test', lastName: 'User' });
+ const newUser = {
+ id: 1,
+ username: 'test',
+ email: 'test@example.com',
+ firstName: 'Test',
+ lastName: 'User',
+ isVerified: false,
+ generateVerificationToken: jest.fn().mockResolvedValue()
+ };
+ User.create.mockResolvedValue(newUser);
+ emailService.sendVerificationEmail.mockResolvedValue();
jwt.sign.mockReturnValueOnce('access').mockReturnValueOnce('refresh');
const response = await request(app)
@@ -679,4 +721,304 @@ describe('Auth Routes', () => {
expect(cookies[1]).toContain('SameSite=Strict');
});
});
+
+ describe('Email Verification', () => {
+ describe('Registration with verification', () => {
+ it('should continue registration even if verification email fails', async () => {
+ User.findOne.mockResolvedValue(null);
+
+ const newUser = {
+ id: 1,
+ username: 'testuser',
+ email: 'test@example.com',
+ firstName: 'Test',
+ lastName: 'User',
+ isVerified: false,
+ generateVerificationToken: jest.fn().mockResolvedValue()
+ };
+
+ User.create.mockResolvedValue(newUser);
+ emailService.sendVerificationEmail.mockRejectedValue(new Error('Email service down'));
+
+ const response = await request(app)
+ .post('/auth/register')
+ .send({
+ username: 'testuser',
+ email: 'test@example.com',
+ password: 'StrongPass123!',
+ firstName: 'Test',
+ lastName: 'User'
+ });
+
+ expect(response.status).toBe(201);
+ expect(response.body.user.id).toBe(1);
+ expect(response.body.verificationEmailSent).toBe(false);
+ expect(newUser.generateVerificationToken).toHaveBeenCalled();
+ });
+ });
+
+ describe('Google OAuth auto-verification', () => {
+ it('should auto-verify Google OAuth users', async () => {
+ const mockPayload = {
+ sub: 'google456',
+ email: 'oauth@gmail.com',
+ given_name: 'OAuth',
+ family_name: 'User',
+ picture: 'pic.jpg'
+ };
+
+ mockGoogleClient.getToken.mockResolvedValue({
+ tokens: { id_token: 'google-id-token' }
+ });
+
+ mockGoogleClient.verifyIdToken.mockResolvedValue({
+ getPayload: () => mockPayload
+ });
+
+ User.findOne
+ .mockResolvedValueOnce(null) // No Google user
+ .mockResolvedValueOnce(null); // No email user
+
+ const createdUser = {
+ id: 1,
+ username: 'oauth_gle456',
+ email: 'oauth@gmail.com',
+ firstName: 'OAuth',
+ lastName: 'User',
+ profileImage: 'pic.jpg',
+ isVerified: true
+ };
+
+ User.create.mockResolvedValue(createdUser);
+ jwt.sign.mockReturnValueOnce('access-token').mockReturnValueOnce('refresh-token');
+
+ const response = await request(app)
+ .post('/auth/google')
+ .send({
+ code: 'google-auth-code'
+ });
+
+ expect(response.status).toBe(200);
+ expect(User.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isVerified: true,
+ verifiedAt: expect.any(Date)
+ })
+ );
+ });
+ });
+
+ describe('POST /auth/verify-email', () => {
+ it('should verify email with valid token', async () => {
+ const mockUser = {
+ id: 1,
+ email: 'test@example.com',
+ verificationToken: 'valid-token-123',
+ verificationTokenExpiry: new Date(Date.now() + 60 * 60 * 1000),
+ isVerified: false,
+ isVerificationTokenValid: function(token) {
+ return this.verificationToken === token &&
+ new Date() < new Date(this.verificationTokenExpiry);
+ },
+ verifyEmail: jest.fn().mockResolvedValue({
+ id: 1,
+ email: 'test@example.com',
+ isVerified: true
+ })
+ };
+
+ User.findOne.mockResolvedValue(mockUser);
+
+ const response = await request(app)
+ .post('/auth/verify-email')
+ .send({ token: 'valid-token-123' });
+
+ expect(response.status).toBe(200);
+ expect(response.body.message).toBe('Email verified successfully');
+ expect(response.body.user).toEqual({
+ id: 1,
+ email: 'test@example.com',
+ isVerified: true
+ });
+ expect(mockUser.verifyEmail).toHaveBeenCalled();
+ });
+
+ it('should reject missing token', async () => {
+ const response = await request(app)
+ .post('/auth/verify-email')
+ .send({});
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('Verification token required');
+ expect(response.body.code).toBe('TOKEN_REQUIRED');
+ });
+
+ it('should reject invalid token', async () => {
+ User.findOne.mockResolvedValue(null);
+
+ const response = await request(app)
+ .post('/auth/verify-email')
+ .send({ token: 'invalid-token' });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('Invalid verification token');
+ expect(response.body.code).toBe('VERIFICATION_TOKEN_INVALID');
+ });
+
+ it('should reject already verified user', async () => {
+ const mockUser = {
+ id: 1,
+ email: 'verified@example.com',
+ isVerified: true
+ };
+
+ User.findOne.mockResolvedValue(mockUser);
+
+ const response = await request(app)
+ .post('/auth/verify-email')
+ .send({ token: 'some-token' });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('Email already verified');
+ expect(response.body.code).toBe('ALREADY_VERIFIED');
+ });
+
+ it('should reject expired token', async () => {
+ const mockUser = {
+ id: 1,
+ email: 'test@example.com',
+ verificationToken: 'expired-token',
+ verificationTokenExpiry: new Date(Date.now() - 60 * 60 * 1000),
+ isVerified: false,
+ isVerificationTokenValid: jest.fn().mockReturnValue(false)
+ };
+
+ User.findOne.mockResolvedValue(mockUser);
+
+ const response = await request(app)
+ .post('/auth/verify-email')
+ .send({ token: 'expired-token' });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('Verification token has expired. Please request a new one.');
+ expect(response.body.code).toBe('VERIFICATION_TOKEN_EXPIRED');
+ });
+
+ it('should handle verification errors', async () => {
+ User.findOne.mockRejectedValue(new Error('Database error'));
+
+ const response = await request(app)
+ .post('/auth/verify-email')
+ .send({ token: 'valid-token' });
+
+ expect(response.status).toBe(500);
+ expect(response.body.error).toBe('Email verification failed. Please try again.');
+ });
+ });
+
+ describe('POST /auth/resend-verification', () => {
+ it('should resend verification email for authenticated unverified user', async () => {
+ const mockUser = {
+ id: 1,
+ email: 'test@example.com',
+ firstName: 'Test',
+ isVerified: false,
+ verificationToken: 'new-token',
+ generateVerificationToken: jest.fn().mockResolvedValue()
+ };
+
+ jwt.verify.mockReturnValue({ id: 1 });
+ User.findByPk.mockResolvedValue(mockUser);
+ emailService.sendVerificationEmail.mockResolvedValue();
+
+ const response = await request(app)
+ .post('/auth/resend-verification')
+ .set('Cookie', ['accessToken=valid-token']);
+
+ expect(response.status).toBe(200);
+ expect(response.body.message).toBe('Verification email sent successfully');
+ expect(mockUser.generateVerificationToken).toHaveBeenCalled();
+ expect(emailService.sendVerificationEmail).toHaveBeenCalledWith(mockUser, mockUser.verificationToken);
+ });
+
+ it('should reject when no access token provided', async () => {
+ const response = await request(app)
+ .post('/auth/resend-verification');
+
+ expect(response.status).toBe(401);
+ expect(response.body.error).toBe('Authentication required');
+ expect(response.body.code).toBe('NO_TOKEN');
+ });
+
+ it('should reject expired access token', async () => {
+ const error = new Error('jwt expired');
+ error.name = 'TokenExpiredError';
+ jwt.verify.mockImplementation(() => {
+ throw error;
+ });
+
+ const response = await request(app)
+ .post('/auth/resend-verification')
+ .set('Cookie', ['accessToken=expired-token']);
+
+ expect(response.status).toBe(401);
+ expect(response.body.error).toBe('Session expired. Please log in again.');
+ expect(response.body.code).toBe('TOKEN_EXPIRED');
+ });
+
+ it('should reject when user not found', async () => {
+ jwt.verify.mockReturnValue({ id: 999 });
+ User.findByPk.mockResolvedValue(null);
+
+ const response = await request(app)
+ .post('/auth/resend-verification')
+ .set('Cookie', ['accessToken=valid-token']);
+
+ expect(response.status).toBe(404);
+ expect(response.body.error).toBe('User not found');
+ expect(response.body.code).toBe('USER_NOT_FOUND');
+ });
+
+ it('should reject when user already verified', async () => {
+ const mockUser = {
+ id: 1,
+ email: 'verified@example.com',
+ isVerified: true
+ };
+
+ jwt.verify.mockReturnValue({ id: 1 });
+ User.findByPk.mockResolvedValue(mockUser);
+
+ const response = await request(app)
+ .post('/auth/resend-verification')
+ .set('Cookie', ['accessToken=valid-token']);
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('Email already verified');
+ expect(response.body.code).toBe('ALREADY_VERIFIED');
+ });
+
+ it('should handle email service failure', async () => {
+ const mockUser = {
+ id: 1,
+ email: 'test@example.com',
+ isVerified: false,
+ verificationToken: 'new-token',
+ generateVerificationToken: jest.fn().mockResolvedValue()
+ };
+
+ jwt.verify.mockReturnValue({ id: 1 });
+ User.findByPk.mockResolvedValue(mockUser);
+ emailService.sendVerificationEmail.mockRejectedValue(new Error('Email service down'));
+
+ const response = await request(app)
+ .post('/auth/resend-verification')
+ .set('Cookie', ['accessToken=valid-token']);
+
+ expect(response.status).toBe(500);
+ expect(response.body.error).toBe('Failed to send verification email. Please try again.');
+ expect(mockUser.generateVerificationToken).toHaveBeenCalled();
+ });
+ });
+ });
});
\ No newline at end of file
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0c04ec0..72c3ede 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -7,6 +7,7 @@ import Home from './pages/Home';
import Login from './pages/Login';
import Register from './pages/Register';
import GoogleCallback from './pages/GoogleCallback';
+import VerifyEmail from './pages/VerifyEmail';
import ItemList from './pages/ItemList';
import ItemDetail from './pages/ItemDetail';
import EditItem from './pages/EditItem';
@@ -38,6 +39,7 @@ function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/components/AuthModal.tsx b/frontend/src/components/AuthModal.tsx
index e13de3b..41d8d9d 100644
--- a/frontend/src/components/AuthModal.tsx
+++ b/frontend/src/components/AuthModal.tsx
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useAuth } from "../contexts/AuthContext";
import PasswordStrengthMeter from "./PasswordStrengthMeter";
+import PasswordInput from "./PasswordInput";
interface AuthModalProps {
show: boolean;
@@ -154,19 +155,18 @@ const AuthModal: React.FC = ({
/>
-
-
Password
-
setPassword(e.target.value)}
- required
- />
- {mode === "signup" && (
+
setPassword(e.target.value)}
+ required
+ />
+ {mode === "signup" && (
+
+
+ )}
) => void;
+ required?: boolean;
+ placeholder?: string;
+ className?: string;
+ label?: string;
+}
+
+const PasswordInput: React.FC = ({
+ id,
+ name,
+ value,
+ onChange,
+ required = false,
+ placeholder,
+ className = 'form-control',
+ label
+}) => {
+ const [showPassword, setShowPassword] = useState(false);
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
+
+ setShowPassword(!showPassword)}
+ style={{ zIndex: 10, textDecoration: 'none' }}
+ tabIndex={-1}
+ >
+
+
+
+
+ );
+};
+
+export default PasswordInput;
diff --git a/frontend/src/components/PasswordStrengthMeter.tsx b/frontend/src/components/PasswordStrengthMeter.tsx
index c9ad897..e5ba2aa 100644
--- a/frontend/src/components/PasswordStrengthMeter.tsx
+++ b/frontend/src/components/PasswordStrengthMeter.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React from "react";
interface PasswordStrengthMeterProps {
password: string;
@@ -13,60 +13,71 @@ interface PasswordRequirement {
const PasswordStrengthMeter: React.FC = ({
password,
- showRequirements = true
+ showRequirements = true,
}) => {
const requirements: PasswordRequirement[] = [
{
regex: /.{8,}/,
text: "At least 8 characters",
- met: /.{8,}/.test(password)
+ met: /.{8,}/.test(password),
},
{
regex: /[a-z]/,
text: "One lowercase letter",
- met: /[a-z]/.test(password)
+ met: /[a-z]/.test(password),
},
{
regex: /[A-Z]/,
- text: "One uppercase letter",
- met: /[A-Z]/.test(password)
+ text: "One uppercase letter",
+ met: /[A-Z]/.test(password),
},
{
regex: /\d/,
text: "One number",
- met: /\d/.test(password)
+ met: /\d/.test(password),
},
{
- regex: /[@$!%*?&]/,
- text: "One special character (@$!%*?&)",
- met: /[@$!%*?&]/.test(password)
- }
+ regex: /[-@$!%*?^]/,
+ text: "One special character (-@$!%*?^)",
+ met: /[-@$!%*?^]/.test(password),
+ },
];
- const getPasswordStrength = (): { score: number; label: string; color: string } => {
- if (!password) return { score: 0, label: '', color: '' };
+ const getPasswordStrength = (): {
+ score: number;
+ label: string;
+ color: string;
+ } => {
+ if (!password) return { score: 0, label: "", color: "" };
+
+ const metRequirements = requirements.filter((req) => req.met).length;
+ const hasCommonPassword = [
+ "password",
+ "123456",
+ "123456789",
+ "qwerty",
+ "abc123",
+ "password123",
+ ].includes(password.toLowerCase());
- const metRequirements = requirements.filter(req => req.met).length;
- const hasCommonPassword = ['password', '123456', '123456789', 'qwerty', 'abc123', 'password123'].includes(password.toLowerCase());
-
if (hasCommonPassword) {
- return { score: 0, label: 'Too Common', color: 'danger' };
+ return { score: 0, label: "Too Common", color: "danger" };
}
switch (metRequirements) {
case 0:
case 1:
- return { score: 1, label: 'Very Weak', color: 'danger' };
+ return { score: 1, label: "Very Weak", color: "danger" };
case 2:
- return { score: 2, label: 'Weak', color: 'warning' };
+ return { score: 2, label: "Weak", color: "warning" };
case 3:
- return { score: 3, label: 'Fair', color: 'info' };
+ return { score: 3, label: "Fair", color: "info" };
case 4:
- return { score: 4, label: 'Good', color: 'primary' };
+ return { score: 4, label: "Good", color: "primary" };
case 5:
- return { score: 5, label: 'Strong', color: 'success' };
+ return { score: 5, label: "Strong", color: "success" };
default:
- return { score: 0, label: '', color: '' };
+ return { score: 0, label: "", color: "" };
}
};
@@ -87,7 +98,7 @@ const PasswordStrengthMeter: React.FC = ({
)}
-
+
= ({
{/* Requirements List */}
{showRequirements && (
-
Password must contain:
-
+
+ Password must contain:
+
+
{requirements.map((requirement, index) => (
-
+
{requirement.text}
@@ -124,4 +141,4 @@ const PasswordStrengthMeter: React.FC = ({
);
};
-export default PasswordStrengthMeter;
\ No newline at end of file
+export default PasswordStrengthMeter;
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
index fab1096..c7bc228 100644
--- a/frontend/src/pages/Login.tsx
+++ b/frontend/src/pages/Login.tsx
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
+import PasswordInput from '../components/PasswordInput';
const Login: React.FC = () => {
const [email, setEmail] = useState('');
@@ -54,19 +55,13 @@ const Login: React.FC = () => {
required
/>
-
-
- Password
-
- setPassword(e.target.value)}
- required
- />
-
+
setPassword(e.target.value)}
+ required
+ />
{
const [formData, setFormData] = useState({
@@ -125,20 +126,14 @@ const Register: React.FC = () => {
onChange={handleChange}
/>
-
-
- Password
-
-
-
+
{
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const { checkAuth, user } = useAuth();
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState(false);
+ const [processing, setProcessing] = useState(true);
+ const [resending, setResending] = useState(false);
+ const hasProcessed = useRef(false);
+
+ useEffect(() => {
+ const handleVerification = async () => {
+ // Prevent double execution in React StrictMode
+ if (hasProcessed.current) {
+ return;
+ }
+ hasProcessed.current = true;
+
+ try {
+ const token = searchParams.get('token');
+
+ if (!token) {
+ setError('No verification token provided.');
+ setProcessing(false);
+ return;
+ }
+
+ // Verify the email with the token
+ await authAPI.verifyEmail(token);
+
+ setSuccess(true);
+ setProcessing(false);
+
+ // Refresh user data to update isVerified status
+ await checkAuth();
+
+ // Redirect to home after 3 seconds
+ setTimeout(() => {
+ navigate('/', { replace: true });
+ }, 3000);
+ } catch (err: any) {
+ console.error('Email verification error:', err);
+ const errorData = err.response?.data;
+
+ if (errorData?.code === 'VERIFICATION_TOKEN_EXPIRED') {
+ setError('Your verification link has expired. Please request a new one.');
+ } else if (errorData?.code === 'VERIFICATION_TOKEN_INVALID') {
+ setError('Invalid verification link. The link may have already been used or is incorrect.');
+ } else if (errorData?.code === 'ALREADY_VERIFIED') {
+ setError('Your email is already verified.');
+ } else {
+ setError(errorData?.error || 'Failed to verify email. Please try again.');
+ }
+
+ setProcessing(false);
+ }
+ };
+
+ handleVerification();
+ }, [searchParams, navigate, checkAuth]);
+
+ const handleResendVerification = async () => {
+ setResending(true);
+ setError('');
+
+ try {
+ await authAPI.resendVerification();
+ setError('');
+ alert('Verification email sent! Please check your inbox.');
+ } catch (err: any) {
+ console.error('Resend verification error:', err);
+ const errorData = err.response?.data;
+
+ if (errorData?.code === 'ALREADY_VERIFIED') {
+ setError('Your email is already verified.');
+ } else if (errorData?.code === 'NO_TOKEN') {
+ setError('You must be logged in to resend the verification email.');
+ } else {
+ setError(errorData?.error || 'Failed to resend verification email. Please try again.');
+ }
+ } finally {
+ setResending(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {processing ? (
+ <>
+
+ Loading...
+
+
Verifying Your Email...
+
Please wait while we verify your email address.
+ >
+ ) : success ? (
+ <>
+
+
Email Verified Successfully!
+
+ Your email has been verified. You will be redirected to the home page shortly.
+
+
+ Go to Home
+
+ >
+ ) : error ? (
+ <>
+
+
Verification Failed
+
{error}
+
+ {user && !error.includes('already verified') && (
+
+ {resending ? 'Sending...' : 'Resend Verification Email'}
+
+ )}
+
+ Return to Home
+
+
+ >
+ ) : null}
+
+
+
+
+
+ );
+};
+
+export default VerifyEmail;
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 8b906c1..f40d0b1 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -166,6 +166,8 @@ export const authAPI = {
refresh: () => api.post("/auth/refresh"),
getCSRFToken: () => api.get("/auth/csrf-token"),
getStatus: () => api.get("/auth/status"),
+ verifyEmail: (token: string) => api.post("/auth/verify-email", { token }),
+ resendVerification: () => api.post("/auth/resend-verification"),
};
export const userAPI = {