From ba91620d0e4ac0d1c95cd9b8b83c4ed4f60cabbf Mon Sep 17 00:00:00 2001 From: Siddharth Sahai Date: Tue, 12 Aug 2025 15:45:38 +0530 Subject: [PATCH] added emailService.js that successfully sends otp and added a couple of routes to manage it --- package-lock.json | 30 ++++--- package.json | 3 +- src/app.js | 9 +- src/models/passwordReset.js | 59 ++++++++++++ src/routes/auth.js | 174 +++++++++++++++++++++++++++++++++++- src/routes/profile.js | 1 - src/utils/emailService.js | 53 +++++++++++ 7 files changed, 307 insertions(+), 22 deletions(-) create mode 100644 src/models/passwordReset.js create mode 100644 src/utils/emailService.js diff --git a/package-lock.json b/package-lock.json index ee2eaf8..6f748c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "express": "^4.21.2", "jsonwebtoken": "^9.0.2", "mongoose": "^8.10.0", - "validator": "^13.12.0" + "nodemailer": "^7.0.5", + "validator": "^13.15.15" }, "devDependencies": { "mocha": "^11.1.0" @@ -374,9 +375,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -1780,9 +1781,9 @@ } }, "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2065,6 +2066,15 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/nodemailer": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz", + "integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -2804,9 +2814,9 @@ } }, "node_modules/validator": { - "version": "13.12.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", - "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "version": "13.15.15", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", + "integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==", "license": "MIT", "engines": { "node": ">= 0.10" diff --git a/package.json b/package.json index 5fe7e3a..6416add 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "express": "^4.21.2", "jsonwebtoken": "^9.0.2", "mongoose": "^8.10.0", - "validator": "^13.12.0" + "nodemailer": "^7.0.5", + "validator": "^13.15.15" }, "devDependencies": { "mocha": "^11.1.0" diff --git a/src/app.js b/src/app.js index 37e6f82..96eed93 100644 --- a/src/app.js +++ b/src/app.js @@ -15,14 +15,7 @@ app.use(cookieParser()); const dotenv = require("dotenv"); dotenv.config(); app.use(cors({ - origin: [ - "https://dev-tinder-frontend-main.vercel.app", - "http://localhost:5173", - "https://dev-tinder-ax9m.onrender.com", - "www.devtinder.engineer", - "https://www.devtinder.engineer", - "https://legendary-waffle-r4rpvxgr79vvcx5pw-5173.app.github.dev" - ], + origin: true, // Allow all origins for development credentials: true, })); diff --git a/src/models/passwordReset.js b/src/models/passwordReset.js new file mode 100644 index 0000000..109babe --- /dev/null +++ b/src/models/passwordReset.js @@ -0,0 +1,59 @@ +const mongoose = require('mongoose'); +const { Schema } = mongoose; + +const passwordResetSchema = new Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + token: { + type: String, + required: true, + unique: true, + index: true + }, + // Store the plain token temporarily to support OTP verification redirect. + // This value is equivalent to what would be sent via email link. + // Do not index this field. + plainToken: { + type: String, + }, + expiresAt: { + type: Date, + required: true, + index: true + }, + used: { + type: Boolean, + default: false + }, + // OTP for email verification (hashed) + otpCode: { + type: String, + }, + // When the OTP expires (typically short-lived, e.g., 10 minutes) + otpExpiresAt: { + type: Date, + }, + // Track incorrect attempts if needed in future + otpAttempts: { + type: Number, + default: 0, + } +}, { + timestamps: true +}); + +// Index for automatic cleanup of expired tokens +passwordResetSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +// Method to check if token is valid and not expired +passwordResetSchema.methods.isValid = function() { + return !this.used && new Date() < this.expiresAt; +}; + +const PasswordReset = mongoose.model('PasswordReset', passwordResetSchema); + +module.exports = PasswordReset; \ No newline at end of file diff --git a/src/routes/auth.js b/src/routes/auth.js index a0d22e2..756a76b 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -2,6 +2,10 @@ const express = require('express'); const bcrypt = require('bcrypt'); const {User} = require('../models/user'); const {validateSignupData} = require("../utils/validation"); +const PasswordReset = require('../models/passwordReset'); +const { sendPasswordResetEmail, sendWelcomeEmail, sendPasswordResetOtpEmail } = require('../utils/emailService'); +const crypto = require('crypto'); +const validator = require('validator'); const authRouter = express.Router(); @@ -17,14 +21,21 @@ authRouter.post("/signup", async (req, res) => { email: emailId, password: hashedPassword }); - const savedUser = await user.save(); + const savedUser = await user.save(); const token = await savedUser.getJWT(); res.cookie("token", token, { expires: new Date(Date.now() + 8 * 3600000), }); - res.json({ message: "User Added successfully!", data: savedUser }); + res.json({ message: "User Added successfully!", data: savedUser }); + + // Send welcome email (optional) + try { + await sendWelcomeEmail(emailId, firstName); + } catch (emailError) { + console.log('Welcome email failed to send:', emailError.message); + } } catch (err) { console.log(err.message); res.status(400).send("Something went wrong: " + err.message); @@ -73,4 +84,163 @@ authRouter.post("/logout", async (req,res) => { res.send("Error: " + err.message); } }) + +// Forgot Password - Request Reset +authRouter.post("/auth/password/forgot", async (req, res) => { + try { + const { emailId } = req.body; + + if (!emailId) { + return res.status(400).json({ message: "Email is required" }); + } + + // Find user by email + const user = await User.findOne({ email: emailId }); + if (!user) { + // Don't reveal if user exists or not for security + return res.json({ message: "If an account with that email exists, a password reset link has been sent." }); + } + + // Generate secure token for reset URL + const resetToken = crypto.randomBytes(32).toString('hex'); + const hashedToken = crypto.createHash('sha256').update(resetToken).digest('hex'); + + // Generate 6-digit OTP code and hash it for storage + const otpCode = (Math.floor(100000 + Math.random() * 900000)).toString(); + const hashedOtp = crypto.createHash('sha256').update(otpCode).digest('hex'); + + // Create password reset record + const passwordReset = new PasswordReset({ + userId: user._id, + token: hashedToken, + plainToken: resetToken, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour for final reset + otpCode: hashedOtp, + otpExpiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes for OTP + otpAttempts: 0, + }); + + await passwordReset.save(); + + // Create reset URL + const resetUrl = `${process.env.FRONTEND_URL || 'http://localhost:5173'}/reset-password/${resetToken}`; + + // Send OTP email instead of direct link + const emailSent = await sendPasswordResetOtpEmail(emailId, otpCode); + + if (emailSent) { + res.json({ message: "If an account with that email exists, an OTP has been sent to your email." }); + } else { + res.status(500).json({ message: "Failed to send OTP email. Please try again." }); + } + + } catch (err) { + console.error('Forgot password error:', err); + res.status(500).json({ message: "Something went wrong. Please try again." }); + } +}); + +// Verify OTP and return reset URL token +authRouter.post("/auth/password/verify-otp", async (req, res) => { + try { + const { emailId, otp } = req.body; + if (!emailId || !otp) { + return res.status(400).json({ message: "Email and OTP are required" }); + } + + const user = await User.findOne({ email: emailId }); + if (!user) { + // do not reveal existence + return res.status(400).json({ message: "Invalid OTP or expired" }); + } + + // Find the latest unused reset request with a valid OTP + const resetRecord = await PasswordReset.findOne({ + userId: user._id, + used: false, + otpExpiresAt: { $gt: new Date() } + }).sort({ createdAt: -1 }); + + if (!resetRecord || !resetRecord.otpCode) { + return res.status(400).json({ message: "Invalid OTP or expired" }); + } + + // Rate limit attempts (optional) + if (resetRecord.otpAttempts >= 5) { + return res.status(429).json({ message: "Too many attempts. Request a new code." }); + } + + const hashedInput = crypto.createHash('sha256').update(otp.toString()).digest('hex'); + if (hashedInput !== resetRecord.otpCode) { + resetRecord.otpAttempts = (resetRecord.otpAttempts || 0) + 1; + await resetRecord.save(); + return res.status(400).json({ message: "Invalid OTP" }); + } + + // OTP is valid. Option 1: immediately mark OTP as used by clearing it. + resetRecord.otpCode = null; + resetRecord.otpExpiresAt = null; + resetRecord.otpAttempts = 0; + await resetRecord.save(); + + // Return the reset URL token to the client to navigate to the reset page + // Or we could also set a short-lived session/cookie. For SPA, returning token is fine. + return res.json({ + message: "OTP verified", + resetToken: resetRecord.plainToken, + }); + } catch (err) { + console.error('Verify OTP error:', err); + res.status(500).json({ message: "Something went wrong. Please try again." }); + } +}); +// Reset Password - Set New Password +authRouter.post("/auth/password/reset/:token", async (req, res) => { + try { + const { token } = req.params; + const { newPassword } = req.body; + + if (!newPassword) { + return res.status(400).json({ message: "New password is required" }); + } + + // Hash the token to compare with stored hash + const hashedToken = crypto.createHash('sha256').update(token).digest('hex'); + + // Find valid password reset record + const passwordReset = await PasswordReset.findOne({ + token: hashedToken, + used: false, + expiresAt: { $gt: new Date() } + }); + + if (!passwordReset) { + return res.status(400).json({ message: "Invalid or expired reset token" }); + } + + // Validate password strength + if (!validator.isStrongPassword(newPassword)) { + return res.status(400).json({ message: "Password is not strong enough" }); + } + + // Hash new password + const hashedPassword = await bcrypt.hash(newPassword, 10); + + // Update user password + await User.findByIdAndUpdate(passwordReset.userId, { + password: hashedPassword + }); + + // Mark token as used + passwordReset.used = true; + await passwordReset.save(); + + res.json({ message: "Password has been reset successfully. You can now login with your new password." }); + + } catch (err) { + console.error('Reset password error:', err); + res.status(500).json({ message: "Something went wrong. Please try again." }); + } +}); + module.exports = authRouter; \ No newline at end of file diff --git a/src/routes/profile.js b/src/routes/profile.js index d4341f6..c44e5eb 100644 --- a/src/routes/profile.js +++ b/src/routes/profile.js @@ -45,7 +45,6 @@ profileRouter.patch("/profile/password/forgot",userAuth, async(req,res)=>{ throw new Error("The Password is no Strong Enough"); } else{ - const loggedInUser = req.user; const {_id} = loggedInUser; const result = await User.findById(_id); diff --git a/src/utils/emailService.js b/src/utils/emailService.js new file mode 100644 index 0000000..f865469 --- /dev/null +++ b/src/utils/emailService.js @@ -0,0 +1,53 @@ +const nodemailer = require('nodemailer'); + +const createTransporter = () => { + const { EMAIL_USER, EMAIL_PASS } = process.env; + if (!EMAIL_USER || !EMAIL_PASS) { + throw new Error('EMAIL_USER/EMAIL_PASS missing in .env'); + } + return nodemailer.createTransport({ + service: 'gmail', + auth: { + user: EMAIL_USER, + pass: EMAIL_PASS, // Gmail App Password (not your normal password) + }, + }); +}; + +const sendPasswordResetEmail = async (userEmail, resetToken, resetUrl) => { + try { + const transporter = createTransporter(); + const info = await transporter.sendMail({ + from: process.env.EMAIL_FROM || process.env.EMAIL_USER, + to: userEmail, + subject: 'DevTinder - Password Reset', + html: `

Click to reset:

${resetUrl}

`, + }); + console.log('Mail sent:', info.messageId); + return true; + } catch (err) { + console.error('Error sending password reset email:', err); + return false; + } +}; + +const sendPasswordResetOtpEmail = async (userEmail, otpCode) => { + try { + const transporter = createTransporter(); + const info = await transporter.sendMail({ + from: process.env.EMAIL_FROM || process.env.EMAIL_USER, + to: userEmail, + subject: 'DevTinder - Your Password Reset Code', + html: `

Your password reset verification code is:

+

${otpCode}

+

This code will expire in 10 minutes. If you didn't request this, you can ignore this email.

`, + }); + console.log('OTP mail sent:', info.messageId); + return true; + } catch (err) { + console.error('Error sending password reset OTP email:', err); + return false; + } +}; + +module.exports = { sendPasswordResetEmail, sendPasswordResetOtpEmail }; \ No newline at end of file