diff --git a/backend/package-lock.json b/backend/package-lock.json index 2af2553..b5af430 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "bcryptjs": "^3.0.2", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", @@ -24,6 +25,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.3", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.9.1", @@ -149,6 +151,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -164,6 +176,7 @@ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -220,6 +233,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -639,6 +653,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -2500,6 +2533,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/backend/package.json b/backend/package.json index 9ecd185..87cf24e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,7 @@ "type": "module", "dependencies": { "bcryptjs": "^3.0.2", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", @@ -27,6 +28,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.3", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.9.1", diff --git a/backend/src/app.ts b/backend/src/app.ts index 390de29..3081a23 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,17 +1,18 @@ -import express from "express"; import dotenv from "dotenv"; +import express from "express"; import authRoutes from "./routes/authRoutes.js"; import healthRoutes from "./routes/healthRoutes.js"; import { errorHandler } from "./middleware/errorHandler.js"; import roomRoutes from "./routes/roomRoutes.js"; import passport from "passport"; import "./utils/passport.js" +import cookieParser from 'cookie-parser'; import cors from "cors"; dotenv.config(); const app = express(); app.use(express.json()); - +app.use(cookieParser()); // <-- Add this middleware HERE app.use( cors({ origin: process.env.FRONTEND_URL || "http://localhost:5173", diff --git a/backend/src/controllers/authController.ts b/backend/src/controllers/authController.ts index 017496a..77391f0 100644 --- a/backend/src/controllers/authController.ts +++ b/backend/src/controllers/authController.ts @@ -1,15 +1,49 @@ import type { Request, Response, NextFunction } from "express"; import bcrypt from "bcryptjs"; +import jwt from "jsonwebtoken"; // <-- ADDED import User, { type IUser } from "../models/userModel.js"; -import { generateToken } from "../utils/generateToken.js"; +import { + generateAccessToken, // <-- RENAMED/UPDATED + generateRefreshToken, // <-- ADDED +} from "../utils/generateToken.js"; import { userSchema, loginSchema } from "../utils/validateInputs.js"; import dotenv from "dotenv"; dotenv.config(); -const asTypedUser = (user: any): IUser & { _id: string } => user as IUser & { _id: string }; +const asTypedUser = (user: any): IUser & { _id: string } => + user as IUser & { _id: string }; -// ✅ SIGNUP CONTROLLER -export const registerUser = async (req: Request, res: Response, next: NextFunction) => { +// A helper function to send tokens +const sendTokens = (res: Response, user: IUser & { _id: string }) => { + const accessToken = generateAccessToken(user._id.toString()); + const newRefreshToken = generateRefreshToken(user._id.toString()); + + // Update user's refresh tokens in DB + // Only one refresh token per user is supported (single device). + user.refreshTokens = [newRefreshToken]; + + // Set refresh token in secure httpOnly cookie + res.cookie("jwt", newRefreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV !== "development", + sameSite: "strict", + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days (matches token expiry) + }); + + // Send access token in response body + res.json({ + success: true, + message: "Login successful", + accessToken: accessToken, + }); +}; + +// ✅ SIGNUP CONTROLLER (Updated) +export const registerUser = async ( + req: Request, + res: Response, + next: NextFunction +) => { try { const parseResult = userSchema.safeParse(req.body); if (!parseResult.success) { @@ -20,31 +54,32 @@ export const registerUser = async (req: Request, res: Response, next: NextFuncti } const { email, password } = parseResult.data; - - // ✅ Auto-derive name from email const name = email.split("@")[0]; const existingUser = await User.findOne({ email }); if (existingUser) { - return res.status(400).json({ success: false, message: "Email already registered" }); + return res + .status(400) + .json({ success: false, message: "Email already registered" }); } const hashedPassword = await bcrypt.hash(password, 10); const newUser = await User.create({ name, email, password: hashedPassword }); const typedUser = asTypedUser(newUser); - res.status(201).json({ - success: true, - message: "User registered successfully", - token: generateToken(typedUser._id.toString()), - }); + // Generate and send tokens + sendTokens(res, typedUser); } catch (err) { next(err); } }; -// ✅ LOGIN CONTROLLER (same as before) -export const loginUser = async (req: Request, res: Response, next: NextFunction) => { +// ✅ LOGIN CONTROLLER (Updated) +export const loginUser = async ( + req: Request, + res: Response, + next: NextFunction +) => { try { const parseResult = loginSchema.safeParse(req.body); if (!parseResult.success) { @@ -57,35 +92,234 @@ export const loginUser = async (req: Request, res: Response, next: NextFunction) const { email, password } = parseResult.data; const foundUser = await User.findOne({ email }); if (!foundUser) - return res.status(400).json({ success: false, message: "Invalid credentials" }); + return res + .status(400) + .json({ success: false, message: "Invalid credentials" }); if (!foundUser.password || foundUser.password === "") { return res.status(400).json({ success: false, - message: "This account was registered via SSO. Please sign in with Google or GitHub.", + message: + "This account was registered via SSO. Please sign in with Google or GitHub.", }); } const isMatch = await bcrypt.compare(password, foundUser.password); if (!isMatch) - return res.status(400).json({ success: false, message: "Invalid credentials" }); + return res + .status(400) + .json({ success: false, message: "Invalid credentials" }); const typedUser = asTypedUser(foundUser); - res.json({ - success: true, - message: "Login successful", - token: generateToken(typedUser._id.toString()), + // Generate and send tokens + await typedUser.save(); // Save any potential changes (like refresh tokens) + sendTokens(res, typedUser); + } catch (err) { + next(err); + } +}; + +// ✅ OAUTH CALLBACK CONTROLLER (New) +export const oauthCallback = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + if (!req.user) { + return res.status(401).json({ success: false, message: "Authentication failed" }); + } + + const typedUser = asTypedUser(req.user); + + // We get the user profile from passport's `done` function + // (which you'd have in src/utils/passport.ts) + // Now we just generate and send tokens + + // Find the user in DB (req.user is from passport) + const foundUser = await User.findById(typedUser._id); + if (!foundUser) { + return res.status(404).json({ success: false, message: "User not found" }); + } + + const typedFoundUser = asTypedUser(foundUser); + + // We send tokens the same way, but redirect the user + const accessToken = generateAccessToken(typedFoundUser._id.toString()); + const newRefreshToken = generateRefreshToken(typedFoundUser._id.toString()); + + typedFoundUser.refreshTokens = [newRefreshToken]; + await typedFoundUser.save(); + + res.cookie("jwt", newRefreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV !== "development", + sameSite: "strict", + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + + // Set the access token in a secure, HTTP-only cookie + res.cookie("access_token", accessToken, { + httpOnly: true, + secure: process.env.NODE_ENV !== "development", + sameSite: "strict", + maxAge: 15 * 60 * 1000, // 15 minutes, adjust as needed }); + + // Redirect to the frontend without passing the token in the URL + const redirectUrl = `${process.env.FRONTEND_URL}/auth-success`; + res.redirect(redirectUrl); + } catch (err) { next(err); } }; -// ✅ GET PROFILE CONTROLLER (unchanged) +// ... (keep all other functions as they are) + +// ✅ REFRESH TOKEN CONTROLLER (Updated with fixes) +export const handleRefreshToken = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const cookies = req.cookies; + if (!cookies?.jwt) { + return res + .status(401) + .json({ success: false, message: "Unauthorized, no token" }); + } + + const refreshToken = cookies.jwt; + // Clear the old cookie immediately + res.clearCookie("jwt", { httpOnly: true, sameSite: "strict", secure: process.env.NODE_ENV !== "development" }); + + const foundUser = await User.findOne({ refreshTokens: refreshToken }); + + // Detected refresh token reuse! + if (!foundUser) { + try { + const decoded = jwt.verify( + refreshToken, + process.env.JWT_REFRESH_SECRET as string + ) as { id: string }; + + // We know who the user is, now we hack-proof them + // by deleting all their refresh tokens + const compromisedUser = await User.findById(decoded.id); + if (compromisedUser) { + compromisedUser.refreshTokens = []; + await compromisedUser.save(); + } + } catch (err) { + // Token was invalid in the first place + } finally { + return res + .status(403) + .json({ success: false, message: "Forbidden, token reuse" }); + } + } + + // Valid token, let's rotate it + const typedUser = asTypedUser(foundUser); + + try { + // Verify the token is still valid + jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET as string) as { + id: string; + }; + + // Generate new tokens + const newAccessToken = generateAccessToken(typedUser._id.toString()); + const newRefreshToken = generateRefreshToken(typedUser._id.toString()); + + // ================== + // FIX #1 + // ================== + // Filter out the old token and default to an empty array + const otherRefreshTokens = + typedUser.refreshTokens?.filter((rt) => rt !== refreshToken) || []; + + // Assign the new array (filtered list + new token) + typedUser.refreshTokens = [...otherRefreshTokens, newRefreshToken]; + await typedUser.save(); + + // Send new tokens + res.cookie("jwt", newRefreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV !== "development", + sameSite: "strict", + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + + res.json({ + success: true, + accessToken: newAccessToken, + }); + } catch (err) { + // Token expired or invalid + // ================== + // FIX #2 + // ================== + // Clear out the bad token, default to an empty array + typedUser.refreshTokens = + typedUser.refreshTokens?.filter((rt) => rt !== refreshToken) || []; + await typedUser.save(); + + return res + .status(403) + .json({ success: false, message: "Forbidden, token invalid or expired" }); + } + } catch (err) { + next(err); + } +}; + + +// ✅ LOGOUT CONTROLLER (Updated with fix) +export const logoutUser = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const cookies = req.cookies; + if (!cookies?.jwt) { + return res.sendStatus(204); // No cookie, already logged out + } + + const refreshToken = cookies.jwt; + + // Find user and remove this specific refresh token + const foundUser = await User.findOne({ refreshTokens: refreshToken }); + if (foundUser) { + foundUser.refreshTokens = + foundUser.refreshTokens?.filter((rt) => rt !== refreshToken) || []; + + await foundUser.save(); + } + + // Clear the cookie + res.clearCookie("jwt", { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV !== "development", + }); + + res.status(200).json({ success: true, message: "Logged out successfully" }); + } catch (err) { + next(err); + } +}; + +// ✅ GET PROFILE CONTROLLER (Unchanged, but for completeness) export const getUserProfile = async (req: Request, res: Response) => { try { - const user = await User.findById(req.userId).select("-password"); + // req.userId comes from the 'protect' middleware + // @ts-ignore + const user = await User.findById(req.userId).select("-password -refreshTokens"); if (!user) { return res.status(404).json({ @@ -105,4 +339,4 @@ export const getUserProfile = async (req: Request, res: Response) => { message: "Server error", }); } -}; +}; \ No newline at end of file diff --git a/backend/src/middleware/authMiddleware.ts b/backend/src/middleware/authMiddleware.ts index 0465cc5..5e6a90e 100644 --- a/backend/src/middleware/authMiddleware.ts +++ b/backend/src/middleware/authMiddleware.ts @@ -9,13 +9,19 @@ export const protect = (req: AuthRequest, res: Response, next: NextFunction) => let token = req.headers.authorization?.split(" ")[1]; if (!token) - return res.status(401).json({ success: false, message: "Not authorized, token missing" }); + return res + .status(401) + .json({ success: false, message: "Not authorized, token missing" }); try { - const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as { id: string }; + const decoded = jwt.verify( + token, + process.env.JWT_ACCESS_SECRET as string // <-- UPDATED ENV VARIABLE + ) as { id: string }; req.userId = decoded.id; next(); } catch { + // Note: This will now correctly trigger a 401 on an expired access token res.status(401).json({ success: false, message: "Invalid token" }); } -}; +}; \ No newline at end of file diff --git a/backend/src/models/userModel.ts b/backend/src/models/userModel.ts index 80fba91..10038af 100644 --- a/backend/src/models/userModel.ts +++ b/backend/src/models/userModel.ts @@ -6,6 +6,7 @@ export interface IUser extends Document { password?: string; // optional for SSO users ssoProvider?: string; // "google" or "github" ssoId?: string; + refreshTokens?: string[]; // <-- ADDED: To store valid refresh tokens createdAt: Date; } @@ -16,6 +17,7 @@ const userSchema: Schema = new Schema( password: { type: String, default: "" }, // empty for SSO accounts ssoProvider: { type: String, enum: ["google", "github"], default: null }, ssoId: { type: String, default: null }, + refreshTokens: [{ type: String }], // <-- ADDED }, { timestamps: true } ); diff --git a/backend/src/routes/authRoutes.ts b/backend/src/routes/authRoutes.ts index 071d48a..62e6926 100644 --- a/backend/src/routes/authRoutes.ts +++ b/backend/src/routes/authRoutes.ts @@ -5,8 +5,11 @@ import { protect } from "../middleware/authMiddleware.js"; const router = express.Router(); +// Auth router.post("/signup", registerUser); router.post("/signin", loginUser); +router.post("/logout", logoutUser); // <-- ADDED +router.get("/refresh", handleRefreshToken); // <-- ADDED router.get("/me", protect, getUserProfile); // Google OAuth diff --git a/backend/src/utils/generateToken.ts b/backend/src/utils/generateToken.ts index 7aa3f73..627171f 100644 --- a/backend/src/utils/generateToken.ts +++ b/backend/src/utils/generateToken.ts @@ -1,5 +1,33 @@ import jwt from "jsonwebtoken"; +import type { SignOptions } from "jsonwebtoken"; +import dotenv from 'dotenv'; +dotenv.config(); +const accessTokenSecret = process.env.JWT_ACCESS_SECRET; -export const generateToken = (id: string) => { - return jwt.sign({ id }, process.env.JWT_SECRET as string, { expiresIn: "7d" }); +const refreshTokenSecret = process.env.JWT_REFRESH_SECRET; + +const parseExpiration = (val: string | undefined, fallback: number | string): number | string => { + if (!val) return fallback; + const trimmed = val.trim(); + return /^\d+$/.test(trimmed) ? Number(trimmed) : trimmed; +}; + +export const generateAccessToken = (id: string) => { + if (!accessTokenSecret) throw new Error("JWT_ACCESS_SECRET is not defined"); + + const options = { + expiresIn: parseExpiration(process.env.JWT_ACCESS_EXPIRATION, 900), + } as SignOptions; + + return jwt.sign({ id }, accessTokenSecret, options); }; + +export const generateRefreshToken = (id: string) => { + if (!refreshTokenSecret) throw new Error("JWT_REFRESH_SECRET is not defined"); + + const options = { + expiresIn: parseExpiration(process.env.JWT_REFRESH_EXPIRATION, "7d"), + } as SignOptions; + + return jwt.sign({ id }, refreshTokenSecret, options); +}; \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ae6eeec..18dafe3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -159,6 +159,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1385,6 +1386,7 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -1468,6 +1470,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -3019,6 +3022,7 @@ "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3320,6 +3324,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -5630,6 +5635,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5831,6 +5837,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5840,6 +5847,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6893,6 +6901,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -7197,6 +7206,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }