diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 9abeb65..c9d10bf 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -1,8 +1,10 @@ import bcrypt from "bcryptjs"; +import crypto from "crypto"; import { OAuth2Client } from "google-auth-library"; import User from "../models/user.model.js"; import { generateTokenAndSetCookie, catchAsync } from "../lib/utils.js"; import cloudinary from "../lib/cloudinary.js"; +import { sendPasswordResetEmail } from "../lib/email.js"; let googleClient; @@ -247,4 +249,108 @@ export const subscribeToPush = catchAsync(async (req, res) => { }).select("-password -__v"); res.status(200).json({ message: "Push subscription saved" }); +}); + +// ── POST /auth/forgot-password ──────────────────────────────────── +/** + * Feat #575: Initiates the password recovery flow. + * Generates a cryptographically secure reset token, stores its SHA-256 hash + * in the database, and sends a one-time reset link to the user's email. + * + * Security notes: + * - The raw token is sent via email ONLY; only the hash is stored in the DB. + * - If no user is found for the email, we respond with 200 to prevent + * user enumeration attacks. + * - Token expires in 1 hour. + */ +export const forgotPassword = catchAsync(async (req, res) => { + const { email } = req.body; + + if (!email || typeof email !== "string") { + return res.status(400).json({ message: "Email address is required" }); + } + + const user = await User.findOne({ email: email.toLowerCase().trim() }); + + // Always respond 200 — prevents leaking whether an email exists in the system + if (!user) { + return res.status(200).json({ message: "If that email is registered, a reset link has been sent." }); + } + + // Generate a random 32-byte token (URL-safe hex string) + const rawToken = crypto.randomBytes(32).toString("hex"); + + // Store only the SHA-256 hash in DB (raw token is sent by email only) + const hashedToken = crypto.createHash("sha256").update(rawToken).digest("hex"); + + user.passwordResetToken = hashedToken; + user.passwordResetExpires = new Date(Date.now() + 60 * 60 * 1000); // 1 hour + await user.save({ validateBeforeSave: false }); + + try { + await sendPasswordResetEmail(user.email, rawToken, user.name); + res.status(200).json({ message: "If that email is registered, a reset link has been sent." }); + } catch (emailErr) { + // Roll back the token on email failure so the user can retry + user.passwordResetToken = null; + user.passwordResetExpires = null; + await user.save({ validateBeforeSave: false }); + console.error("[forgotPassword] Failed to send email:", emailErr.message); + res.status(500).json({ message: "Failed to send reset email. Please try again later." }); + } +}); + +// ── POST /auth/reset-password/:token ───────────────────────────── +/** + * Feat #575: Completes the password reset flow. + * Validates the one-time token, hashes the new password, clears the token, + * and logs the user in with a fresh JWT session cookie. + * + * Security notes: + * - Token is re-hashed on receipt and compared with the DB hash. + * - Expired tokens are rejected even if they match. + * - Token is consumed (nulled) after successful use to prevent replay attacks. + */ +export const resetPassword = catchAsync(async (req, res) => { + const { token } = req.params; + const { password } = req.body; + + if (!token || !password) { + return res.status(400).json({ message: "Token and new password are required" }); + } + + if (password.length < 6) { + return res.status(400).json({ message: "Password must be at least 6 characters" }); + } + + // Re-hash the incoming raw token and look for a matching, non-expired record + const hashedToken = crypto.createHash("sha256").update(token).digest("hex"); + + const user = await User.findOne({ + passwordResetToken: hashedToken, + passwordResetExpires: { $gt: new Date() }, // Token must not be expired + }).select("+passwordResetToken +passwordResetExpires"); + + if (!user) { + return res.status(400).json({ message: "Reset link is invalid or has expired. Please request a new one." }); + } + + // Hash the new password and persist + const salt = await bcrypt.genSalt(12); + user.password = await bcrypt.hash(password, salt); + + // Consume the token — invalidates it for any future use + user.passwordResetToken = null; + user.passwordResetExpires = null; + await user.save(); + + // Log the user in immediately by issuing a fresh JWT session cookie + generateTokenAndSetCookie(user._id, res); + + const safeUser = user.toObject(); + delete safeUser.password; + delete safeUser.passwordResetToken; + delete safeUser.passwordResetExpires; + + res.status(200).json(safeUser); }); \ No newline at end of file diff --git a/backend/src/lib/email.js b/backend/src/lib/email.js new file mode 100644 index 0000000..7f685c8 --- /dev/null +++ b/backend/src/lib/email.js @@ -0,0 +1,148 @@ +/** + * email.js — Nodemailer email service (Feat #575: Password Recovery) + * + * Provides a reusable `sendEmail` helper that sends transactional emails + * using a configurable SMTP transport (Gmail, SendGrid, Mailgun, etc.). + * + * Environment variables required: + * EMAIL_HOST - SMTP host (e.g. "smtp.gmail.com") + * EMAIL_PORT - SMTP port (e.g. 587 for TLS, 465 for SSL) + * EMAIL_USER - SMTP username / sender email address + * EMAIL_PASSWORD - SMTP password / app password + * EMAIL_FROM - "From" display name + address (e.g. "App Name ") + */ + +import nodemailer from "nodemailer"; + +/** + * Creates a Nodemailer transport using SMTP environment config. + * Falls back to Ethereal (fake SMTP) when EMAIL_HOST is not set, + * so development works out-of-the-box without a real email account. + */ +const createTransport = async () => { + if (!process.env.EMAIL_HOST) { + // Ethereal fake SMTP for local development — emails are captured at ethereal.email + const testAccount = await nodemailer.createTestAccount(); + console.log("[Email] Using Ethereal test account:", testAccount.user); + + return nodemailer.createTransport({ + host: "smtp.ethereal.email", + port: 587, + auth: { + user: testAccount.user, + pass: testAccount.pass, + }, + }); + } + + return nodemailer.createTransport({ + host: process.env.EMAIL_HOST, + port: parseInt(process.env.EMAIL_PORT || "587"), + secure: parseInt(process.env.EMAIL_PORT || "587") === 465, + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASSWORD, + }, + }); +}; + +/** + * Sends a transactional email. + * @param {Object} options + * @param {string} options.to - Recipient email address + * @param {string} options.subject - Email subject line + * @param {string} options.html - HTML email body + * @param {string} [options.text] - Plain-text fallback (auto-generated if omitted) + * @returns {Promise} + */ +export const sendEmail = async ({ to, subject, html, text }) => { + const transport = await createTransport(); + + const info = await transport.sendMail({ + from: process.env.EMAIL_FROM || `"Chat App" `, + to, + subject, + html, + text: text || html.replace(/<[^>]*>/g, ""), + }); + + if (!process.env.EMAIL_HOST) { + // Log the Ethereal preview URL in development + console.log("[Email] Preview URL:", nodemailer.getTestMessageUrl(info)); + } +}; + +/** + * Sends a password reset email containing a one-time reset link. + * @param {string} toEmail - Recipient's email address + * @param {string} resetToken - The raw (unhashed) reset token + * @param {string} userName - Recipient's display name (for personalisation) + * @returns {Promise} + */ +export const sendPasswordResetEmail = async (toEmail, resetToken, userName) => { + const clientUrl = process.env.CLIENT_URL || "http://localhost:5173"; + const resetUrl = `${clientUrl}/reset-password/${resetToken}`; + + const html = ` + + + + + + Reset Your Password + + + +
+
+

🔐 Reset Your Password

+
+
+

Hi ${userName},

+

We received a request to reset the password for your Chat App account. + Click the button below to choose a new password:

+ +

Or copy this link into your browser:
${resetUrl}

+
+ ⚠️ This link expires in 1 hour. If you didn't request a + password reset, you can safely ignore this email — your password won't change. +
+
+ +
+ + + `; + + await sendEmail({ + to: toEmail, + subject: "Reset your Chat App password", + html, + }); +}; diff --git a/backend/src/models/user.model.js b/backend/src/models/user.model.js index 5f6aead..170139a 100644 --- a/backend/src/models/user.model.js +++ b/backend/src/models/user.model.js @@ -52,6 +52,17 @@ const userSchema = new mongoose.Schema({ default: null, trim: true, }, + // Password recovery (Feat #575) + passwordResetToken: { + type: String, + default: null, + select: false, // Never returned in normal queries for security + }, + passwordResetExpires: { + type: Date, + default: null, + select: false, + }, }, { timestamps: true }); userSchema.index({ name: "text" }); diff --git a/backend/src/routes/auth.route.js b/backend/src/routes/auth.route.js index 3db8be9..ccc64d5 100644 --- a/backend/src/routes/auth.route.js +++ b/backend/src/routes/auth.route.js @@ -1,7 +1,7 @@ import express from "express"; import protectRoute from "../middleware/auth.middleware.js"; import { validateSignup, validateLogin } from "../middleware/validate.js"; -import { signup, login, logout, googleAuth, updateProfile, updateProfilePicture, checkAuth, subscribeToPush } from "../controllers/auth.controller.js"; +import { signup, login, logout, googleAuth, updateProfile, updateProfilePicture, checkAuth, subscribeToPush, forgotPassword, resetPassword } from "../controllers/auth.controller.js"; const router = express.Router(); @@ -13,5 +13,8 @@ router.put("/update-profile", protectRoute, updateProfile); router.put("/update-profile-picture", protectRoute, updateProfilePicture); router.get("/check", protectRoute, checkAuth); router.post("/push-subscribe", protectRoute, subscribeToPush); +// Feat #575: Password recovery routes (public — no auth required) +router.post("/forgot-password", forgotPassword); +router.post("/reset-password/:token", resetPassword); export default router; \ No newline at end of file diff --git a/frontend/pages/ForgotPasswordPage.jsx b/frontend/pages/ForgotPasswordPage.jsx new file mode 100644 index 0000000..969bb1b --- /dev/null +++ b/frontend/pages/ForgotPasswordPage.jsx @@ -0,0 +1,117 @@ +import { useState } from "react" +import { Link } from "react-router-dom" +import { MessageSquare, Mail, Loader2, ArrowLeft } from "lucide-react" +import useAuthStore from "../src/store/useAuthStore" + +export default function ForgotPasswordPage() { + const { forgotPassword, isLoading } = useAuthStore() + const [email, setEmail] = useState("") + const [submitted, setSubmitted] = useState(false) + + const handleSubmit = async (e) => { + e.preventDefault() + try { + await forgotPassword(email) + setSubmitted(true) + } catch { + // Error handled by store + } + } + + return ( +
+
+
+
+
+
+ +
+

Recover Password

+

+ {submitted + ? "Check your email for reset instructions" + : "Enter your email to receive a password reset link" + } +

+
+
+ + {submitted ? ( +
+
+ An email has been sent to {email} with instructions to reset your password. Please check your inbox and spam folders. +
+ + + Back to Sign In + +
+ ) : ( +
+
+ + +
+ + + +
+ + Back to Sign In + +
+
+ )} +
+
+ +
+
+
+ +
+
+ +
+

Forgot your password?

+

+ Don't worry, it happens to the best of us. Just provide your email address, and we'll help you secure and regain access to your account. +

+
+
+
+ ) +} diff --git a/frontend/pages/LoginPage.jsx b/frontend/pages/LoginPage.jsx index fbfb9b8..21f290f 100644 --- a/frontend/pages/LoginPage.jsx +++ b/frontend/pages/LoginPage.jsx @@ -96,9 +96,14 @@ export default function LoginPage() {
- +
+ + + Forgot password? + +