diff --git a/backend/.env.example b/backend/.env.example index e4cef80..8499eba 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,6 +7,7 @@ NODE_ENV=development # Authentication JWT_SECRET=your_jwt_secret +REFRESH_TOKEN_SECRET=your_refresh_token_secret # Frontend URL CLIENT_URL=http://localhost:5173 diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index ce9a7cf..d51fe76 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -1,9 +1,10 @@ -import { generateToken } from "../lib/utils.js"; +import { generateToken, generateRefreshToken } from "../lib/utils.js"; import User from "../models/user.model.js"; import bcrypt from "bcryptjs"; import cloudinary from "../lib/cloudinary.js"; import { sendWelcomeEmail, sendOtpEmail, sendVerificationOtpEmail } from "../lib/sendEmail.js"; import crypto from "crypto"; +import jwt from "jsonwebtoken"; //signup // Get fullName, email, password from req.body @@ -196,6 +197,11 @@ export const verifyEmail = async (req, res) => { user.emailVerificationOtp = null; user.emailVerificationOtpExpiry = null; + const token = generateToken(user._id); + const refreshToken = generateRefreshToken(user._id); + const refreshTokenHash = crypto.createHash("sha256").update(refreshToken).digest("hex"); + user.refreshTokenHash = refreshTokenHash; + await user.save(); // Send welcome email asynchronously @@ -213,7 +219,13 @@ export const verifyEmail = async (req, res) => { } }); - const token = generateToken(user._id); + res.cookie("refreshToken", refreshToken, { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + return res.status(200).json({ _id: user._id, fullName: user.fullName, @@ -302,6 +314,18 @@ export const login = async (req, res) => { } const token = generateToken(user._id); + const refreshToken = generateRefreshToken(user._id); + const refreshTokenHash = crypto.createHash("sha256").update(refreshToken).digest("hex"); + + user.refreshTokenHash = refreshTokenHash; + await user.save(); + + res.cookie("refreshToken", refreshToken, { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + maxAge: 7 * 24 * 60 * 60 * 1000, + }); res.status(200).json({ _id: user._id, @@ -327,12 +351,24 @@ export const login = async (req, res) => { // Clear JWT cookie by setting empty value // Set cookie expiration to 0 // Send success response -export const logout = (req, res) => { +export const logout = async (req, res) => { try { + const refreshToken = req.cookies?.refreshToken; + if (refreshToken) { + const hash = crypto.createHash("sha256").update(refreshToken).digest("hex"); + await User.findOneAndUpdate({ refreshTokenHash: hash }, { refreshTokenHash: null }); + } + res.cookie("jwt", "", { maxAge: 0, }); + res.clearCookie("refreshToken", { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + }); + res.status(200).json({ message: "Logged out successfully", }); @@ -348,6 +384,55 @@ export const logout = (req, res) => { } }; +// refresh token +// Read refresh token from HTTP-only cookie +// Verify JWT +// Verify stored refresh token matches the user +// Generate and return a new access token +export const refresh = async (req, res) => { + try { + const refreshToken = req.cookies?.refreshToken; + if (!refreshToken) { + return res.status(400).json({ + message: "Refresh token is missing", + }); + } + + let decoded; + try { + decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET); + } catch (err) { + return res.status(401).json({ + message: "Invalid or expired refresh token", + }); + } + + const hash = crypto.createHash("sha256").update(refreshToken).digest("hex"); + const user = await User.findOne({ + _id: decoded.userId, + refreshTokenHash: hash, + isBanned: false, + }); + + if (!user) { + return res.status(401).json({ + message: "Invalid or expired refresh token", + }); + } + + const newAccessToken = generateToken(user._id); + + return res.status(200).json({ + token: newAccessToken, + }); + } catch (error) { + console.error("Refresh error:", error.message); + return res.status(500).json({ + message: "Internal Server Error", + }); + } +}; + //update profile // Get profilePic from req.body // Get authenticated user ID from req.user diff --git a/backend/src/lib/utils.js b/backend/src/lib/utils.js index ddb137e..4d989fb 100644 --- a/backend/src/lib/utils.js +++ b/backend/src/lib/utils.js @@ -6,6 +6,16 @@ export const generateToken = (userId) => { } return jwt.sign({ userId }, process.env.JWT_SECRET, { + expiresIn: "15m", + }); +}; + +export const generateRefreshToken = (userId) => { + if (!process.env.REFRESH_TOKEN_SECRET) { + throw new Error("REFRESH_TOKEN_SECRET is not defined"); + } + + return jwt.sign({ userId }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: "7d", }); }; \ No newline at end of file diff --git a/backend/src/middleware/auth.middleware.js b/backend/src/middleware/auth.middleware.js index 71e49a3..da71505 100644 --- a/backend/src/middleware/auth.middleware.js +++ b/backend/src/middleware/auth.middleware.js @@ -5,11 +5,18 @@ export const protectRoute = async (req, res, next) => { try { const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith("Bearer ")) { + if (!authHeader) { return res.status(401).json({ message: "Unauthorized - No Token" }); } - const token = authHeader.split(" ")[1]; + const trimmedHeader = authHeader.trim(); + const parts = trimmedHeader.split(/\s+/); + + if (parts.length < 2 || parts[0] !== "Bearer") { + return res.status(401).json({ message: "Unauthorized - No Token" }); + } + + const token = parts[1]; const decoded = jwt.verify(token, process.env.JWT_SECRET); diff --git a/backend/src/models/user.model.js b/backend/src/models/user.model.js index 013c88d..8f66b34 100644 --- a/backend/src/models/user.model.js +++ b/backend/src/models/user.model.js @@ -134,6 +134,12 @@ const userSchema = new mongoose.Schema( type: Date, default: null, }, + + refreshTokenHash: { + type: String, + select: false, + default: null, + }, }, { timestamps: true }, ); diff --git a/backend/src/routes/auth.route.js b/backend/src/routes/auth.route.js index e820d37..ad2be49 100644 --- a/backend/src/routes/auth.route.js +++ b/backend/src/routes/auth.route.js @@ -13,6 +13,7 @@ import { getSecurityQuestions, sendOtp, verifyOtp, + refresh, } from "../controllers/auth.controller.js"; import { protectRoute } from "../middleware/auth.middleware.js"; @@ -25,6 +26,7 @@ router.post("/verify-email", otpRateLimiter, verifyEmail); router.post("/resend-verification-otp", otpRateLimiter, resendVerificationOtp); router.post("/login", login); router.post("/logout", logout); +router.post("/refresh", refresh); router.get("/check", protectRoute, checkAuth); router.put("/update-profile", protectRoute, updateProfile); router.put("/security-questions", protectRoute, setupSecurityQuestions); diff --git a/backend/test/auth/refresh_test_runner.js b/backend/test/auth/refresh_test_runner.js new file mode 100644 index 0000000..f6dc93c --- /dev/null +++ b/backend/test/auth/refresh_test_runner.js @@ -0,0 +1,221 @@ +process.env.NODE_ENV = "test"; +process.env.MONGODB_URI = "mongodb://127.0.0.1:27017/refresh_test_db"; +process.env.JWT_SECRET = "test-jwt-secret-key-do-not-use-in-production"; +process.env.REFRESH_TOKEN_SECRET = "test-refresh-token-secret-key-do-not-use-in-production"; + +import request from "supertest"; +import mongoose from "mongoose"; +import bcrypt from "bcryptjs"; +import jwt from "jsonwebtoken"; + +// Dynamically import app and models after env variables are set +const { app } = await import("../../src/index.js"); +const { default: User } = await import("../../src/models/user.model.js"); + +const runTests = async () => { + console.log("Connecting to test MongoDB..."); + if (mongoose.connection.readyState === 0) { + await mongoose.connect(process.env.MONGODB_URI); + } + console.log("Connected. Cleaning collections..."); + await User.deleteMany({}); + + // 1. Create a verified user for testing + const password = "password123"; + const hashedPassword = await bcrypt.hash(password, 10); + const user = await User.create({ + fullName: "Test User", + email: "test@example.com", + password: hashedPassword, + isVerified: true, + securityQuestions: [ + { question: "What is your pet name?", answer: await bcrypt.hash("fluffy", 10) }, + { question: "What is your birth city?", answer: await bcrypt.hash("seattle", 10) }, + { question: "What is your favorite color?", answer: await bcrypt.hash("blue", 10) }, + ], + }); + + console.log("Created test user: test@example.com"); + + // 2. Perform Login + console.log("Testing POST /api/auth/login..."); + const loginRes = await request(app) + .post("/api/auth/login") + .send({ email: "test@example.com", password }); + + if (loginRes.status !== 200) { + throw new Error(`Login failed: ${JSON.stringify(loginRes.body)}`); + } + + const { token } = loginRes.body; + if (!token) { + throw new Error("Access token missing from login response"); + } + console.log("✓ Login returned 200 and access token"); + + // Verify cookie is set + const cookies = loginRes.headers["set-cookie"] || []; + const refreshCookie = cookies.find(c => c.startsWith("refreshToken=")); + if (!refreshCookie) { + throw new Error("refreshToken cookie not set in login response headers"); + } + + // Assert security properties on the cookie + const cookieParts = refreshCookie.split(";").map(part => part.trim().toLowerCase()); + const hasHttpOnly = cookieParts.includes("httponly"); + const hasStrictSameSite = cookieParts.includes("samesite=strict"); + const hasMaxAge7Days = cookieParts.includes("max-age=604800"); + + if (!hasHttpOnly) { + throw new Error("refreshToken cookie is missing 'HttpOnly' attribute"); + } + if (!hasStrictSameSite) { + throw new Error("refreshToken cookie is missing 'SameSite=Strict' attribute"); + } + if (!hasMaxAge7Days) { + throw new Error(`refreshToken cookie is missing 7-day expiration attribute (Max-Age=604800). Got: ${refreshCookie}`); + } + + console.log("✓ refreshToken cookie set with HttpOnly, SameSite=Strict, and 7-day lifetime"); + + // Verify hash is saved in DB + const userInDb = await User.findById(user._id).select("+refreshTokenHash"); + if (!userInDb.refreshTokenHash) { + throw new Error("refreshTokenHash is not stored in MongoDB"); + } + console.log("✓ refreshTokenHash successfully stored in MongoDB"); + + // 3. Test POST /api/auth/refresh + console.log("Testing POST /api/auth/refresh with valid token..."); + const refreshRes = await request(app) + .post("/api/auth/refresh") + .set("Cookie", [refreshCookie]); + + if (refreshRes.status !== 200) { + throw new Error(`Refresh failed: ${JSON.stringify(refreshRes.body)}`); + } + + const newAccessToken = refreshRes.body.token; + if (!newAccessToken) { + throw new Error("New access token missing from refresh response"); + } + console.log("✓ Refresh returned 200 and new access token"); + + // 4. Test POST /api/auth/refresh with missing token + console.log("Testing POST /api/auth/refresh with missing token..."); + const missingRes = await request(app) + .post("/api/auth/refresh"); + + if (missingRes.status !== 400) { + throw new Error(`Expected 400 for missing refresh token, got ${missingRes.status}`); + } + console.log("✓ Refresh with missing token returned 400"); + + // 5. Test POST /api/auth/refresh with invalid token + console.log("Testing POST /api/auth/refresh with invalid token..."); + const invalidRes = await request(app) + .post("/api/auth/refresh") + .set("Cookie", ["refreshToken=invalid_token_value_here"]); + + if (invalidRes.status !== 401) { + throw new Error(`Expected 401 for invalid refresh token, got ${invalidRes.status}`); + } + console.log("✓ Refresh with invalid token returned 401"); + + // 5b. Test POST /api/auth/refresh with expired token + console.log("Testing POST /api/auth/refresh with expired token..."); + const expiredToken = jwt.sign( + { userId: user._id.toString() }, + process.env.REFRESH_TOKEN_SECRET, + { expiresIn: "-1h" } + ); + const expiredRes = await request(app) + .post("/api/auth/refresh") + .set("Cookie", [`refreshToken=${expiredToken}`]); + + if (expiredRes.status !== 401) { + throw new Error(`Expected 401 for expired refresh token, got ${expiredRes.status}`); + } + console.log("✓ Refresh with expired token returned 401"); + + // 6. Test POST /api/auth/logout + console.log("Testing POST /api/auth/logout..."); + const logoutRes = await request(app) + .post("/api/auth/logout") + .set("Cookie", [refreshCookie]); + + if (logoutRes.status !== 200) { + throw new Error(`Logout failed: ${JSON.stringify(logoutRes.body)}`); + } + console.log("✓ Logout returned 200"); + + // Verify cookie is cleared in response headers + const clearedCookies = logoutRes.headers["set-cookie"] || []; + const clearedRefreshCookie = clearedCookies.find(c => c.startsWith("refreshToken=")); + if (!clearedRefreshCookie) { + throw new Error("refreshToken cookie set-cookie header not found in logout response"); + } + const cookieLower = clearedRefreshCookie.toLowerCase(); + if (!cookieLower.includes("max-age=0") && !cookieLower.includes("expires=")) { + throw new Error(`refreshToken cookie not cleared in logout response headers. Got: ${clearedRefreshCookie}`); + } + console.log("✓ refreshToken cookie is cleared in logout response headers"); + + // Verify hash is removed from DB + const loggedOutUserInDb = await User.findById(user._id).select("+refreshTokenHash"); + if (loggedOutUserInDb.refreshTokenHash !== null) { + throw new Error(`refreshTokenHash in MongoDB was not cleared. Got: ${loggedOutUserInDb.refreshTokenHash}`); + } + console.log("✓ refreshTokenHash in MongoDB successfully set to null"); + + // 7. Test POST /api/auth/refresh after logout (using the revoked token) + console.log("Testing POST /api/auth/refresh with revoked token..."); + const revokedRes = await request(app) + .post("/api/auth/refresh") + .set("Cookie", [refreshCookie]); + + if (revokedRes.status !== 401) { + throw new Error(`Expected 401 for revoked refresh token, got ${revokedRes.status}`); + } + console.log("✓ Refresh with revoked token returned 401"); + + // 8. Test POST /api/auth/refresh with a banned user + console.log("Testing POST /api/auth/refresh with a banned user..."); + const newLoginRes = await request(app) + .post("/api/auth/login") + .send({ email: "test@example.com", password }); + + const newCookies = newLoginRes.headers["set-cookie"] || []; + const newRefreshCookie = newCookies.find(c => c.startsWith("refreshToken=")); + if (!newRefreshCookie) { + throw new Error("Failed to obtain a new refresh token cookie for banned user testing"); + } + + // Ban the user in the database + await User.updateOne({ _id: user._id }, { isBanned: true }); + + const bannedRes = await request(app) + .post("/api/auth/refresh") + .set("Cookie", [newRefreshCookie]); + + // Restore the user status + await User.updateOne({ _id: user._id }, { isBanned: false }); + + if (bannedRes.status !== 401) { + throw new Error(`Expected 401 for banned user refresh token, got ${bannedRes.status}`); + } + console.log("✓ Refresh with banned user returned 401"); + + console.log("\nALL REFRESH TOKEN TESTS PASSED SUCCESSFULLY! 🎉"); +}; + +runTests() + .then(async () => { + await mongoose.connection.close(); + process.exit(0); + }) + .catch(async (err) => { + console.error("Test failed:", err); + await mongoose.connection.close(); + process.exit(1); + }); diff --git a/backend/test/setup.js b/backend/test/setup.js index aafc922..6fb93fc 100644 --- a/backend/test/setup.js +++ b/backend/test/setup.js @@ -2,6 +2,8 @@ process.env.NODE_ENV = "test"; process.env.JWT_SECRET = "test-jwt-secret-key-do-not-use-in-production"; +process.env.REFRESH_TOKEN_SECRET = "test-refresh-secret-key-do-not-use-in-production"; + process.env.GROQ_API_KEY = "gsk_test_key"; process.env.REDIS_URL = "redis://localhost:6379";