From e5cb1377320c55b0fc2678a346101498a24d546c Mon Sep 17 00:00:00 2001 From: Mohd Azeem Malik Date: Sat, 18 Apr 2026 07:31:00 +0530 Subject: [PATCH] Update CSRF logic, Now CSRF is send in both cokkie and token form, No DB checks for csrf only JWT stateless checking --- src/config/corsConfig.ts | 2 +- src/middleware/security/requireCsrf.ts | 82 ++++++++----------- src/modules/auth/auth.controller.ts | 55 ++++++++----- src/modules/auth/auth.service.ts | 5 -- .../auth/utils/createSessionPayload.ts | 5 +- src/modules/user/user.controller.ts | 8 +- src/types/Auth.ts | 1 - src/utils/generateAllCookieTokens.ts | 22 +++-- src/utils/tokens.ts | 12 ++- 9 files changed, 97 insertions(+), 95 deletions(-) diff --git a/src/config/corsConfig.ts b/src/config/corsConfig.ts index 160d6e9..7709453 100644 --- a/src/config/corsConfig.ts +++ b/src/config/corsConfig.ts @@ -14,7 +14,7 @@ const getCorsConfig = (): CorsOptions => { } }, - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-CSRF-Token'], credentials: true, diff --git a/src/middleware/security/requireCsrf.ts b/src/middleware/security/requireCsrf.ts index 60dad21..80a8075 100644 --- a/src/middleware/security/requireCsrf.ts +++ b/src/middleware/security/requireCsrf.ts @@ -1,72 +1,56 @@ -import crypto from 'crypto'; +import jwt from 'jsonwebtoken'; import { ALLOWED_ORIGINS } from '@constants/globalConts.js'; import { UnauthorizedError } from '@middleware/error/index.js'; import { NextFunction, Request, Response } from 'express'; -import logger from '@utils/logger.js'; const unsafeMethods = ['POST', 'PUT', 'PATCH', 'DELETE']; -const normalizeToken = (val: string): string => { - try { - return decodeURIComponent(val).replace(/^"|"$/g, '').trim(); - } catch { - return val.replace(/^"|"$/g, '').trim(); - } -}; - -const safeCompare = (a: string, b: string): boolean => { - const aBuf = Buffer.from(a); - const bBuf = Buffer.from(b); - - if (aBuf.length !== bBuf.length) return false; - - return crypto.timingSafeEqual(aBuf, bBuf); -}; - export const csrfMiddleware = (req: Request, _res: Response, next: NextFunction) => { - if (!unsafeMethods.includes(req.method)) { - return next(); - } + if (!unsafeMethods.includes(req.method)) return next(); const rawOrigin = req.headers.origin || req.headers.referer; - if (rawOrigin) { - try { - const originUrl = new URL(rawOrigin as string).origin; + if (!rawOrigin) { + return next(new UnauthorizedError('Missing origin')); + } + + let origin: string; + try { + origin = new URL(rawOrigin as string).origin; + } catch { + return next(new UnauthorizedError('Invalid origin format')); + } - if (!ALLOWED_ORIGINS.includes(originUrl)) { - return next(new UnauthorizedError('Invalid origin')); - } - } catch { - return next(new UnauthorizedError('Invalid origin format')); - } + if (!ALLOWED_ORIGINS.includes(origin)) { + return next(new UnauthorizedError('Invalid origin')); } - const csrfHeaderRaw = req.headers['x-csrf-token']; - const csrfHeader = Array.isArray(csrfHeaderRaw) ? csrfHeaderRaw[0] : csrfHeaderRaw; + const headerTokenRaw = req.headers['x-csrf-token']; + const headerToken = Array.isArray(headerTokenRaw) ? headerTokenRaw[0] : headerTokenRaw; - const csrfCookieRaw = req.cookies['csrf-token']; + const cookieToken = req.cookies?.['csrf_token']; - if (!csrfHeader || !csrfCookieRaw) { - return next(new UnauthorizedError('CSRF credentials missing')); + if (!headerToken || !cookieToken) { + return next(new UnauthorizedError('Missing CSRF token')); } - const csrfToken = normalizeToken(csrfHeader); - const csrfCookie = normalizeToken(csrfCookieRaw); + if (headerToken !== cookieToken) { + return next(new UnauthorizedError('CSRF token mismatch')); + } - const isValid = safeCompare(csrfToken, csrfCookie); + let decoded: any; + try { + decoded = jwt.verify(headerToken, process.env.CSRF_TOKEN_SECRET!); + } catch { + return next(new UnauthorizedError('Invalid CSRF token')); + } - if (!isValid) { - logger.warn(`'CSRF DEBUG', - header: ${csrfHeader}, - cookie: ${csrfCookieRaw}, - normalizedHeader: ${csrfToken}, - normalizedCookie: ${csrfCookie}, - headerLength: ${csrfToken.length}, - cookieLength: ${csrfCookie.length}, - `); + if (!req.sessionId || decoded.sessionId !== req.sessionId) { + return next(new UnauthorizedError('Invalid CSRF session')); + } - return next(new UnauthorizedError('Invalid CSRF token')); + if (decoded.userId && req.user?.id && decoded.userId !== req.user.id) { + return next(new UnauthorizedError('CSRF user mismatch')); } return next(); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 394dea7..0b72dd2 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -21,9 +21,6 @@ import type { } from '@validation/auth.schema.js'; import { sendSuccess } from '@shared/response.js'; -// ─── Cookie Configuration ─────────────────────────────────────────────────── - -const CSRF_AGE = 15 * 24 * 60 * 60 * 1000; // 15 days const REFRESH_AGE = 15 * 24 * 60 * 60 * 1000; // 15 days const ACCESS_AGE = 30 * 60 * 1000; // 30 minutes @@ -33,9 +30,9 @@ const cookieOptions: CookieOptions = { sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', domain: process.env.NODE_ENV === 'production' ? process.env.PARENT_DOMAIN : undefined, maxAge: ACCESS_AGE, + path: '/', }; -/** Sets the three auth cookies (access / refresh / csrf) on the response. */ function setAuthCookies( res: Response, tokens: { accessToken: string; refreshToken: string; csrfToken: string } @@ -43,11 +40,13 @@ function setAuthCookies( res .cookie('access-token', tokens.accessToken, { ...cookieOptions, maxAge: ACCESS_AGE }) .cookie('refresh-token', tokens.refreshToken, { ...cookieOptions, maxAge: REFRESH_AGE }) - .cookie('csrf-token', tokens.csrfToken, { ...cookieOptions, httpOnly: false, maxAge: CSRF_AGE }); + .cookie('csrf_token', tokens.csrfToken, { + ...cookieOptions, + httpOnly: false, + maxAge: REFRESH_AGE, + }); } -// ─── Controllers ──────────────────────────────────────────────────────────── - export const registerController = async (req: Request, res: Response) => { const body = req.validatedBody as RegisterInput; const userAgent = req.headers['user-agent'] || 'unknown'; @@ -67,6 +66,7 @@ export const registerController = async (req: Request, res: Response) => { avatarUrl: user.avatarUrl ?? null, }, }, + csrfToken: tokens.csrfToken, }); }; @@ -90,34 +90,45 @@ export const loginController = async (req: Request, res: Response) => { accountStatus: user.accountStatus, }, }, + csrfToken: tokens.csrfToken, }); }; -export const forgotPasswordController = async (req: Request, res: Response) => { - const { email } = req.validatedBody as ForgetPasswordInput; - await createPasswordResetRequest(email); - return sendSuccess(res, null, 'If an account with that email exists, a reset link has been sent.'); -}; - -export const resetPasswordController = async (req: Request, res: Response) => { - const { token, password } = req.validatedBody as { token: string; password: string }; - await resetPassword(token, password); - return sendSuccess(res, null, 'Password updated. Please log in with your new password.'); -}; - export const refreshTokenController = async (req: Request, res: Response) => { const reqRefreshToken = req.cookies['refresh-token']; const userAgent = req.headers['user-agent'] || 'unknown'; const ip = req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.ip || req.socket.remoteAddress; + const csrfToken = req.cookies?.['csrf_token']; if (!reqRefreshToken) { throw new UnauthenticatedError('No refresh token, authentication failed'); } + if (!csrfToken) { + throw new UnauthenticatedError('Missing CSRF token'); + } + const tokens = await refreshTokens(reqRefreshToken, userAgent, ip); - setAuthCookies(res, tokens); + const allTokens = { ...tokens, csrfToken }; + setAuthCookies(res, allTokens); + + return res.status(200).json({ + message: 'Tokens refreshed successfully', + data: null, + csrfToken: csrfToken, + }); +}; - return sendSuccess(res, null, 'Tokens refreshed'); +export const forgotPasswordController = async (req: Request, res: Response) => { + const { email } = req.validatedBody as ForgetPasswordInput; + await createPasswordResetRequest(email); + return sendSuccess(res, null, 'If an account with that email exists, a reset link has been sent.'); +}; + +export const resetPasswordController = async (req: Request, res: Response) => { + const { token, password } = req.validatedBody as { token: string; password: string }; + await resetPassword(token, password); + return sendSuccess(res, null, 'Password updated. Please log in with your new password.'); }; export const emailVerificationController = async (req: Request, res: Response) => { @@ -146,7 +157,7 @@ export const logoutController = async (req: Request, res: Response) => { return res .clearCookie('access-token') .clearCookie('refresh-token') - .clearCookie('csrf-token') + .clearCookie('csrf_token') .status(200) .json({ message: 'Logged out successfully', data: null }); }; diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 0626d5b..abdd249 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -54,8 +54,6 @@ export async function registerUser(input: RegisterInputWithMeta): Promise { @@ -210,8 +208,6 @@ export async function refreshTokens( const newRefreshToken = generateRefreshToken(user._id, session.sessionId); - const newCsrfToken = generateCsrfToken(); - await SessionModel.updateOne( { _id: session._id, @@ -228,7 +224,6 @@ export async function refreshTokens( return { accessToken: newAccessToken, refreshToken: newRefreshToken, - csrfToken: newCsrfToken, }; } diff --git a/src/modules/auth/utils/createSessionPayload.ts b/src/modules/auth/utils/createSessionPayload.ts index f852425..ff4f1c9 100644 --- a/src/modules/auth/utils/createSessionPayload.ts +++ b/src/modules/auth/utils/createSessionPayload.ts @@ -2,7 +2,7 @@ import { SessionData } from '../../../models/Session.js'; import { generateAllCookieTokens } from '@utils/generateAllCookieTokens.js'; import { getIpInfo } from '@utils/getIpInfo.js'; import { parseUserAgent } from '@utils/parseUserAgent.js'; -import { hashToken } from '@utils/tokens.js'; +import { generateCsrfToken, hashToken } from '@utils/tokens.js'; import crypto from 'crypto'; import { User } from '@models/User.js'; @@ -18,12 +18,13 @@ export async function createSessionPayload({ const ipData: any = await getIpInfo(ip); const userAgentInfo = parseUserAgent(userAgent); const sessionId = crypto.randomBytes(32).toString('hex'); - const { accessToken, refreshToken, csrfToken } = generateAllCookieTokens( + const { accessToken, refreshToken } = generateAllCookieTokens( user._id, sessionId, user.role, user.accountStatus ); + const csrfToken = generateCsrfToken(user._id, sessionId); let sessionPayload: Partial = { sessionId, diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index a5ce712..5d8061d 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -28,12 +28,16 @@ import { updateProfile, } from './user.service.js'; import { sendSuccess } from '@shared/response.js'; -import { BadRequestError } from '@middleware/error/index.js'; +import { BadRequestError, UnauthorizedError } from '@middleware/error/index.js'; export const getProfileController = async (req: Request, res: Response) => { const userId = req.user!.id; const user = await getProfile(userId); - return sendSuccess(res, user, 'Profile retrieved successfully'); + + return res.status(200).json({ + message: 'Profile retrieved successfully', + data: user, + }); }; export const updateProfileController = async (req: Request, res: Response) => { diff --git a/src/types/Auth.ts b/src/types/Auth.ts index c265e71..d889908 100644 --- a/src/types/Auth.ts +++ b/src/types/Auth.ts @@ -19,5 +19,4 @@ export type SignOutput = { export interface RefreshTokenOutput { accessToken: string; refreshToken: string; - csrfToken: string; } diff --git a/src/utils/generateAllCookieTokens.ts b/src/utils/generateAllCookieTokens.ts index e39d39a..9c9e8c6 100644 --- a/src/utils/generateAllCookieTokens.ts +++ b/src/utils/generateAllCookieTokens.ts @@ -1,10 +1,14 @@ -import { Types } from "mongoose"; -import { generateAccessToken, generateCsrfToken, generateRefreshToken } from "./tokens.js"; -import { AccountStatus } from "@constants/userConsts.js"; +import { Types } from 'mongoose'; +import { generateAccessToken, generateRefreshToken } from './tokens.js'; +import { AccountStatus } from '@constants/userConsts.js'; -export const generateAllCookieTokens = (userId:Types.ObjectId, sessionId:string, role:string, accountStatus:AccountStatus) => { - const accessToken = generateAccessToken(userId, sessionId, role, accountStatus); - const refreshToken = generateRefreshToken(userId, sessionId); - const csrfToken = generateCsrfToken(); - return { accessToken, refreshToken, csrfToken }; -} \ No newline at end of file +export const generateAllCookieTokens = ( + userId: Types.ObjectId, + sessionId: string, + role: string, + accountStatus: AccountStatus +) => { + const accessToken = generateAccessToken(userId, sessionId, role, accountStatus); + const refreshToken = generateRefreshToken(userId, sessionId); + return { accessToken, refreshToken }; +}; diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index cc77169..ae1faf3 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -10,6 +10,9 @@ const ACCESS_TOKEN_SECRET: string = process.env.ACCESS_TOKEN_SECRET!; const REFRESH_TOKEN_EXPIRES_IN: SignOptions['expiresIn'] = process.env .REFRESH_TOKEN_EXPIRES_IN as SignOptions['expiresIn']; const REFRESH_TOKEN_SECRET: string = process.env.REFRESH_TOKEN_SECRET!; +const CSRF_TOKEN_EXPIRES_IN: SignOptions['expiresIn'] = process.env + .CSRF_TOKEN_EXPIRES_IN as SignOptions['expiresIn']; +const CSRF_TOKEN_SECRET: string = process.env.CSRF_TOKEN_SECRET!; export function generateVerificationToken(bytes = 32) { const raw = crypto.randomBytes(bytes).toString('hex'); @@ -60,13 +63,14 @@ export function generateAccessToken( expiresIn: ACCESS_TOKEN_EXPIRES_IN, }); } -export function generateRefreshToken(userId: Types.ObjectId, sessionId: string, reduceTime: boolean = false) { +export function generateRefreshToken(userId: Types.ObjectId, sessionId: string) { return jwt.sign({ userId, sessionId }, REFRESH_TOKEN_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRES_IN, }); } -export function generateCsrfToken() { - const token = crypto.randomBytes(24).toString('hex'); - return token; +export function generateCsrfToken(userId: Types.ObjectId, sessionId: string) { + return jwt.sign({ userId, sessionId }, CSRF_TOKEN_SECRET, { + expiresIn: CSRF_TOKEN_EXPIRES_IN, + }); }