Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions backend/src/controllers/auth.controller.js
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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);
});
148 changes: 148 additions & 0 deletions backend/src/lib/email.js
Original file line number Diff line number Diff line change
@@ -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 <no-reply@example.com>")
*/

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<void>}
*/
export const sendEmail = async ({ to, subject, html, text }) => {
const transport = await createTransport();

const info = await transport.sendMail({
from: process.env.EMAIL_FROM || `"Chat App" <no-reply@chatapp.com>`,
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<void>}
*/
export const sendPasswordResetEmail = async (toEmail, resetToken, userName) => {
const clientUrl = process.env.CLIENT_URL || "http://localhost:5173";
const resetUrl = `${clientUrl}/reset-password/${resetToken}`;

const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Reset Your Password</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f9fafb; margin: 0; padding: 0; }
.container { max-width: 520px; margin: 40px auto; background: #fff;
border-radius: 12px; overflow: hidden;
box-shadow: 0 4px 24px rgba(0,0,0,0.08); }
.header { background: linear-gradient(135deg, #6366f1, #8b5cf6);
padding: 32px; text-align: center; }
.header h1 { color: #fff; margin: 0; font-size: 24px; font-weight: 700; }
.body { padding: 32px; color: #374151; }
.body p { line-height: 1.6; margin: 0 0 16px; }
.btn { display: inline-block; padding: 14px 32px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff !important; text-decoration: none;
border-radius: 8px; font-weight: 600; font-size: 15px;
margin: 8px 0 24px; }
.warning { background: #fef3c7; border-left: 4px solid #f59e0b;
padding: 12px 16px; border-radius: 4px; font-size: 13px;
color: #92400e; margin-top: 16px; }
.footer { background: #f3f4f6; padding: 20px 32px;
font-size: 12px; color: #9ca3af; text-align: center; }
.url-fallback { font-size: 12px; color: #9ca3af; word-break: break-all; margin-top: 8px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔐 Reset Your Password</h1>
</div>
<div class="body">
<p>Hi <strong>${userName}</strong>,</p>
<p>We received a request to reset the password for your Chat App account.
Click the button below to choose a new password:</p>
<div style="text-align: center; margin: 28px 0;">
<a href="${resetUrl}" class="btn">Reset My Password</a>
</div>
<p class="url-fallback">Or copy this link into your browser:<br/>${resetUrl}</p>
<div class="warning">
⚠️ This link expires in <strong>1 hour</strong>. If you didn't request a
password reset, you can safely ignore this email — your password won't change.
</div>
</div>
<div class="footer">
Chat App · This is an automated message, please do not reply.
</div>
</div>
</body>
</html>
`;

await sendEmail({
to: toEmail,
subject: "Reset your Chat App password",
html,
});
};
11 changes: 11 additions & 0 deletions backend/src/models/user.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
5 changes: 4 additions & 1 deletion backend/src/routes/auth.route.js
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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;
Loading