From c9f18338af3105b3040ef5b7566808105a214554 Mon Sep 17 00:00:00 2001 From: Ramjat19 Date: Tue, 4 Nov 2025 23:56:01 +0000 Subject: [PATCH 1/2] Implemented refresh token rotation and secure cookie option --- .gitignore | 2 + backend/package-lock.json | 31 +++++ backend/package.json | 2 + backend/src/app.ts | 6 + backend/src/models/RefreshToken.ts | 30 ++++ backend/src/routes/auth.ts | 166 ++++++++++++++++++++-- backend/src/services/SocketService.ts | 47 +++++-- frontend/frontend/src/App.tsx | 17 ++- frontend/frontend/src/api/index.ts | 184 ++++++++++++++++++++++++- frontend/frontend/src/pages/Login.tsx | 5 +- frontend/frontend/src/pages/Signup.tsx | 17 ++- 11 files changed, 465 insertions(+), 42 deletions(-) create mode 100644 backend/src/models/RefreshToken.ts diff --git a/.gitignore b/.gitignore index 0ccb8df..1d9d0d5 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,5 @@ dist vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ + +Temp folder/ \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 9d490ad..5b9f894 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,7 @@ "@types/express-validator": "^2.20.33", "@types/socket.io": "^3.0.1", "bcryptjs": "^3.0.2", + "cookie-parser": "^1.4.6", "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.3", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/jest": "^30.0.0", @@ -1449,6 +1451,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/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -2705,6 +2717,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", diff --git a/backend/package.json b/backend/package.json index eb5180a..665c214 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,6 +21,7 @@ "@types/socket.io": "^3.0.1", "bcryptjs": "^3.0.2", "cors": "^2.8.5", + "cookie-parser": "^1.4.6", "dotenv": "^17.2.3", "express": "^5.1.0", "express-rate-limit": "^8.2.0", @@ -44,6 +45,7 @@ "supertest": "^7.1.4", "ts-jest": "^29.4.4", "ts-node-dev": "^2.0.0", + "@types/cookie-parser": "^1.4.3", "typescript": "^5.9.2" } } diff --git a/backend/src/app.ts b/backend/src/app.ts index abdef4d..e6db743 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,4 +1,8 @@ +import dotenv from "dotenv"; +dotenv.config(); + import express from "express"; +import cookieParser from 'cookie-parser'; import { createServer } from "http"; import cors from "cors"; import { @@ -30,6 +34,8 @@ export function createApp() { app.use(helmetConfig); // Security headers app.use(securityHeaders); // Additional custom security headers app.use(corsConfig); // CORS policy + // Cookie parser (needed for refresh token cookie parsing) + app.use(cookieParser()); app.use(requestSizeLimiter); // Request size limiting // Note: Rate limiting now applied per-route for better control diff --git a/backend/src/models/RefreshToken.ts b/backend/src/models/RefreshToken.ts new file mode 100644 index 0000000..6abf58b --- /dev/null +++ b/backend/src/models/RefreshToken.ts @@ -0,0 +1,30 @@ +import mongoose, { Schema, Document } from 'mongoose'; + +export interface IRefreshToken extends Document { + user: mongoose.Types.ObjectId; + tokenHash: string; + expiresAt: Date; + createdAt: Date; + createdByIp?: string; + revoked?: boolean; + revokedAt?: Date; + revokedByIp?: string; + replacedByToken?: string; +} + +const refreshTokenSchema = new Schema( + { + user: { type: Schema.Types.ObjectId, ref: 'User', required: true }, + tokenHash: { type: String, required: true, index: true }, + expiresAt: { type: Date, required: true }, + createdAt: { type: Date, default: Date.now }, + createdByIp: { type: String }, + revoked: { type: Boolean, default: false }, + revokedAt: { type: Date }, + revokedByIp: { type: String }, + replacedByToken: { type: String } + }, + { timestamps: false } +); + +export default mongoose.model('RefreshToken', refreshTokenSchema); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index e6896fb..5e99c97 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -2,13 +2,38 @@ import { Router, Response } from "express"; import User from "../models/User"; import bcrypt from "bcryptjs"; import jwt from "jsonwebtoken"; +import crypto from 'crypto'; import authMiddleware, { AuthRequest } from "../middleware/auth"; import { validateLogin, validateSignup } from "../middleware/validation"; import { authLimiter } from "../middleware/security"; import { validateSecureStrings, logSecurityEvent } from "../utils/security"; +import RefreshToken from "../models/RefreshToken"; const router = Router(); +// Refresh token settings +const REFRESH_TOKEN_EXPIRES_DAYS = parseInt(process.env.REFRESH_TOKEN_DAYS || '7', 10); +const REFRESH_TOKEN_EXPIRES_MS = REFRESH_TOKEN_EXPIRES_DAYS * 24 * 60 * 60 * 1000; +const REFRESH_COOKIE_NAME = process.env.REFRESH_COOKIE_NAME || 'refreshToken'; +// Access token settings (shorter lifetime for better security) +const ACCESS_TOKEN_EXPIRES = process.env.ACCESS_TOKEN_EXPIRES || '15m'; // default 15 minutes + +const cookieOptions = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'none' as 'none' | 'lax' | 'strict', + path: '/', + maxAge: REFRESH_TOKEN_EXPIRES_MS +}; + +function generateRefreshToken() { + return crypto.randomBytes(64).toString('hex'); +} + +function hashToken(token: string) { + return crypto.createHash('sha256').update(token).digest('hex'); +} + // Register router.post("/signup", authLimiter, validateSignup, async (req: AuthRequest, res: Response) => { try { @@ -43,13 +68,33 @@ router.post("/signup", authLimiter, validateSignup, async (req: AuthRequest, res // Log successful registration (without sensitive data) console.log(`New user registered: ${username} (${email})`); + // Issue JWT and refresh token on signup + const token = jwt.sign( + { id: newUser._id, email: newUser.email, username: newUser.username }, + (process.env.JWT_SECRET as any), + ({ expiresIn: ACCESS_TOKEN_EXPIRES, issuer: 'collab-code-review', audience: 'collab-code-review-users' } as any) + ); + + const refreshTokenPlain = generateRefreshToken(); + const refreshTokenHash = hashToken(refreshTokenPlain); + const refreshTokenDoc = new RefreshToken({ + user: newUser._id, + tokenHash: refreshTokenHash, + expiresAt: new Date(Date.now() + REFRESH_TOKEN_EXPIRES_MS), + createdByIp: req.ip + }); + await refreshTokenDoc.save(); + res.cookie(REFRESH_COOKIE_NAME, refreshTokenPlain, cookieOptions); + res.status(201).json({ message: "User registered successfully", + token, user: { id: newUser._id, username: newUser.username, email: newUser.email - } + }, + expiresIn: ACCESS_TOKEN_EXPIRES }); } catch (err) { console.error('Registration error:', err); @@ -98,20 +143,25 @@ router.post("/login", authLimiter, validateLogin, async (req: AuthRequest, res: // Generate JWT with stronger settings const token = jwt.sign( - { - id: user._id, - email: user.email, - username: user.username, - iat: Math.floor(Date.now() / 1000) - }, - process.env.JWT_SECRET as string, - { - expiresIn: "24h", - issuer: "collab-code-review", - audience: "collab-code-review-users" - } + { id: user._id, email: user.email, username: user.username }, + (process.env.JWT_SECRET as any), + ({ expiresIn: ACCESS_TOKEN_EXPIRES, issuer: 'collab-code-review', audience: 'collab-code-review-users' } as any) ); + // Create refresh token (rotate on login) + const refreshTokenPlain = generateRefreshToken(); + const refreshTokenHash = hashToken(refreshTokenPlain); + const refreshTokenDoc = new RefreshToken({ + user: user._id, + tokenHash: refreshTokenHash, + expiresAt: new Date(Date.now() + REFRESH_TOKEN_EXPIRES_MS), + createdByIp: req.ip + }); + await refreshTokenDoc.save(); + + // Set refresh token cookie (HttpOnly + Secure in production) + res.cookie(REFRESH_COOKIE_NAME, refreshTokenPlain, cookieOptions); + // Log successful login console.log(`Successful login: ${user.username} from IP: ${req.ip}`); @@ -123,7 +173,7 @@ router.post("/login", authLimiter, validateLogin, async (req: AuthRequest, res: username: user.username, email: user.email }, - expiresIn: "24h" + expiresIn: ACCESS_TOKEN_EXPIRES }); } catch (err) { console.error('Login error:', err); @@ -146,4 +196,92 @@ router.get("/me", authMiddleware, async (req: AuthRequest, res) => { } }); +// Refresh access token (rotate refresh token) +router.post('/refresh', async (req: AuthRequest, res: Response) => { + try { + const token = req.cookies?.[REFRESH_COOKIE_NAME]; + if (!token || typeof token !== 'string') { + return res.status(401).json({ error: 'No refresh token provided' }); + } + + const tokenHash = hashToken(token); + const existing = await RefreshToken.findOne({ tokenHash }); + if (!existing) { + logSecurityEvent('INVALID_REFRESH_TOKEN', 'Refresh token not found in DB', req); + return res.status(401).json({ error: 'Invalid refresh token' }); + } + + if (existing.revoked) { + // Possible token reuse - revoke all user's refresh tokens + await RefreshToken.updateMany({ user: existing.user }, { revoked: true, revokedAt: new Date() }); + logSecurityEvent('REUSED_REFRESH_TOKEN', `Revoked all tokens for user ${existing.user}`, req); + return res.status(401).json({ error: 'Refresh token revoked' }); + } + + if (existing.expiresAt < new Date()) { + return res.status(401).json({ error: 'Refresh token expired' }); + } + + // Rotate: create new refresh token, revoke old + const newTokenPlain = generateRefreshToken(); + const newTokenHash = hashToken(newTokenPlain); + + const newTokenDoc = new RefreshToken({ + user: existing.user, + tokenHash: newTokenHash, + expiresAt: new Date(Date.now() + REFRESH_TOKEN_EXPIRES_MS), + createdByIp: req.ip + }); + await newTokenDoc.save(); + + existing.revoked = true; + existing.revokedAt = new Date(); + existing.revokedByIp = (req.ip as string) || 'unknown'; + existing.replacedByToken = newTokenHash; + await existing.save(); + + // Issue new access token + const user = await User.findById(existing.user); + if (!user) return res.status(401).json({ error: 'User not found' }); + + const accessToken = jwt.sign( + { id: user._id, email: user.email, username: user.username }, + (process.env.JWT_SECRET as any), + ({ expiresIn: ACCESS_TOKEN_EXPIRES, issuer: 'collab-code-review', audience: 'collab-code-review-users' } as any) + ); + + // Set cookie with new refresh token + res.cookie(REFRESH_COOKIE_NAME, newTokenPlain, cookieOptions); + + res.json({ message: 'Token refreshed', token: accessToken, expiresIn: ACCESS_TOKEN_EXPIRES }); + } catch (err) { + console.error('Refresh token error:', err); + res.status(500).json({ error: 'Unable to refresh token' }); + } +}); + +// Logout - revoke refresh token and clear cookie +router.post('/logout', authMiddleware, async (req: AuthRequest, res: Response) => { + try { + const token = req.cookies?.[REFRESH_COOKIE_NAME]; + if (token && typeof token === 'string') { + const tokenHash = hashToken(token); + const existing = await RefreshToken.findOne({ tokenHash }); + if (existing) { + existing.revoked = true; + existing.revokedAt = new Date(); + existing.revokedByIp = (req.ip as string) || 'unknown'; + await existing.save(); + } + } + + // Clear cookie + res.clearCookie(REFRESH_COOKIE_NAME, { path: '/' }); + res.json({ message: 'Logged out' }); + } catch (err) { + console.error('Logout error:', err); + res.status(500).json({ error: 'Unable to logout' }); + } +}); + export default router; diff --git a/backend/src/services/SocketService.ts b/backend/src/services/SocketService.ts index 5bdfc90..1754738 100644 --- a/backend/src/services/SocketService.ts +++ b/backend/src/services/SocketService.ts @@ -18,22 +18,41 @@ class SocketService { } constructor(server: HttpServer) { + // Build allowed origins from env and sensible defaults + const envList = (process.env.FRONTEND_URLS || process.env.FRONTEND_URL || '') + .split(',') + .map(s => s.trim()) + .filter(Boolean); + + const allowedOrigins = [ + "http://localhost:3000", + "http://localhost:5173", + "http://127.0.0.1:3000", + "http://127.0.0.1:5173", + // example Vercel production/preview domains + 'https://collab-code-review.vercel.app', + 'https://collab-code-review-ram-prasads-projects-12031425.vercel.app', + 'https://collab-code-review-git-main-ram-prasads-projects-12031425.vercel.app', + 'https://collab-code-review-77hz1mncv-ram-prasads-projects-12031425.vercel.app', + ...envList + ]; + + const allowedRegex = [ + /https?:\/\/[a-z0-9-]+\.vercel\.app(:\d+)?$/i, + /https?:\/\/[a-z0-9-]+\.vercel\.dev(:\d+)?$/i + ]; + this.io = new SocketServer(server, { cors: { - origin: [ - "http://localhost:3000", - "http://localhost:5173", - "http://localhost:5174", - "http://localhost:5175", - "http://localhost:5176", - "http://localhost:5177", - "http://127.0.0.1:3000", - "http://127.0.0.1:5173", - "http://127.0.0.1:5174", - "http://127.0.0.1:5175", - "http://127.0.0.1:5176", - "http://127.0.0.1:5177" - ], + origin: (origin: string | undefined, callback: (err: any, allowed?: boolean) => void) => { + // allow local tools / server (no origin) + if (!origin) return callback(null, true); + if (allowedOrigins.indexOf(origin) !== -1 || allowedRegex.some(r => r.test(origin))) { + return callback(null, true); + } + console.warn(`Socket.IO CORS blocked origin: ${origin}`); + return callback(new Error('Not allowed by CORS')); + }, methods: ["GET", "POST"], credentials: true } diff --git a/frontend/frontend/src/App.tsx b/frontend/frontend/src/App.tsx index 972e2c4..658f054 100644 --- a/frontend/frontend/src/App.tsx +++ b/frontend/frontend/src/App.tsx @@ -14,6 +14,7 @@ import SettingsPage from './pages/SettingsPage'; import NotificationBell from './components/NotificationBell'; import { ErrorProvider } from './contexts/ErrorContext'; import ErrorBoundary from './components/ErrorBoundary'; +import API, { clearAuthToken } from './api'; function App() { const [isAuthenticated, setIsAuthenticated] = useState(!!localStorage.getItem('token')); @@ -78,10 +79,18 @@ function App() {