Skip to content

Commit 162ea00

Browse files
mahmoodhamdiclaude
andcommitted
feat: add httpOnly cookies, CSRF protection, and saved posts feature
Security Improvements: - Migrate JWT tokens from localStorage to httpOnly cookies - Add CSRF protection middleware for state-changing requests - Update auth routes to include CSRF validation - Update frontend to include CSRF tokens in requests New Features: - Add save/bookmark posts functionality for authenticated users - Add savedPosts field to User model - Add endpoints: POST/DELETE /blog/posts/:slug/save, GET /blog/saved Technical Changes: - Add cookies.ts utility for cookie management - Add csrf.ts middleware for CSRF token generation and validation - Update auth middleware to read tokens from cookies (with header fallback) - Update frontend API client to handle cookie-based auth - Add production readiness plan documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 768138c commit 162ea00

File tree

16 files changed

+953
-109
lines changed

16 files changed

+953
-109
lines changed

backend/src/app.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { env, morganStream } from './config';
1919
import { swaggerSpec } from './config/swagger';
2020
import { errorHandler } from './middlewares/errorHandler';
2121
import { notFoundHandler } from './middlewares/notFoundHandler';
22+
import { csrfTokenGenerator, CSRF_COOKIE_NAME } from './middlewares/csrf';
2223
import healthRouter from './routes/health.routes';
2324
import authRouter from './routes/auth.routes';
2425
import settingsRouter from './routes/settings.routes';
@@ -75,7 +76,8 @@ export function createApp(): Express {
7576
origin: env.corsOrigin.split(','),
7677
credentials: true,
7778
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
78-
allowedHeaders: ['Content-Type', 'Authorization', 'Accept-Language'],
79+
allowedHeaders: ['Content-Type', 'Authorization', 'Accept-Language', 'X-CSRF-Token'],
80+
exposedHeaders: ['X-CSRF-Token'],
7981
})
8082
);
8183

@@ -102,6 +104,19 @@ export function createApp(): Express {
102104
// Cookie parser
103105
app.use(cookieParser());
104106

107+
// CSRF token generator (generates token for all requests)
108+
app.use(csrfTokenGenerator);
109+
110+
// CSRF token endpoint - returns the current CSRF token
111+
app.get('/api/v1/csrf-token', (req, res) => {
112+
res.json({
113+
success: true,
114+
data: {
115+
csrfToken: req.cookies?.[CSRF_COOKIE_NAME] || req.csrfToken,
116+
},
117+
});
118+
});
119+
105120
// Compression
106121
app.use(compression());
107122

backend/src/config/env.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ const envSchema = Joi.object({
4848
// Sentry
4949
SENTRY_DSN: Joi.string().allow('').optional(),
5050

51+
// Cookie
52+
COOKIE_DOMAIN: Joi.string().allow('').optional(),
53+
5154
// Firebase
5255
FIREBASE_PROJECT_ID: Joi.string().allow('').optional(),
5356
FIREBASE_PRIVATE_KEY_ID: Joi.string().allow('').optional(),
@@ -117,6 +120,13 @@ export const env = {
117120
// Sentry
118121
sentryDsn: envVars.SENTRY_DSN as string | undefined,
119122

123+
// Cookie settings
124+
cookie: {
125+
domain: envVars.COOKIE_DOMAIN as string | undefined,
126+
secure: envVars.NODE_ENV === 'production',
127+
sameSite: (envVars.NODE_ENV === 'production' ? 'strict' : 'lax') as 'strict' | 'lax' | 'none',
128+
},
129+
120130
// Firebase
121131
firebase: {
122132
projectId: envVars.FIREBASE_PROJECT_ID as string | undefined,

backend/src/controllers/auth.controller.ts

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { User } from '../models';
88
import { authService, emailService } from '../services';
99
import { ApiError, Errors } from '../utils/ApiError';
1010
import { sendSuccess, sendCreated } from '../utils/response';
11+
import { setAuthCookies, clearAuthCookies, COOKIE_NAMES } from '../utils/cookies';
1112
import { asyncHandler } from '../middlewares';
1213
import { logger } from '../config';
1314
import { verifyIdToken } from '../config/firebase';
@@ -51,6 +52,9 @@ export const register = asyncHandler(async (req: Request, res: Response) => {
5152
// Generate tokens
5253
const tokens = await authService.generateTokenPair(user, req.headers['user-agent'], req.ip);
5354

55+
// Set httpOnly cookies
56+
setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
57+
5458
sendCreated(
5559
res,
5660
{
@@ -61,7 +65,9 @@ export const register = asyncHandler(async (req: Request, res: Response) => {
6165
role: user.role,
6266
isEmailVerified: user.isEmailVerified,
6367
},
64-
...tokens,
68+
// Include tokens in response for backward compatibility
69+
accessToken: tokens.accessToken,
70+
refreshToken: tokens.refreshToken,
6571
},
6672
'Registration successful. Please verify your email | تم التسجيل بنجاح. يرجى تأكيد بريدك الإلكتروني'
6773
);
@@ -114,6 +120,9 @@ export const login = asyncHandler(async (req: Request, res: Response) => {
114120
// Generate tokens
115121
const tokens = await authService.generateTokenPair(user, req.headers['user-agent'], req.ip);
116122

123+
// Set httpOnly cookies
124+
setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
125+
117126
sendSuccess(
118127
res,
119128
{
@@ -125,7 +134,9 @@ export const login = asyncHandler(async (req: Request, res: Response) => {
125134
avatar: user.avatar,
126135
isEmailVerified: user.isEmailVerified,
127136
},
128-
...tokens,
137+
// Include tokens in response for backward compatibility
138+
accessToken: tokens.accessToken,
139+
refreshToken: tokens.refreshToken,
129140
},
130141
{ message: 'Login successful | تم تسجيل الدخول بنجاح' }
131142
);
@@ -137,8 +148,10 @@ export const login = asyncHandler(async (req: Request, res: Response) => {
137148
* POST /api/v1/auth/logout
138149
*/
139150
export const logout = asyncHandler(async (req: Request, res: Response) => {
140-
const { refreshToken } = req.body;
141-
const accessToken = req.headers.authorization?.split(' ')[1];
151+
// Get tokens from cookies or body (for backward compatibility)
152+
const refreshToken = req.cookies?.[COOKIE_NAMES.REFRESH_TOKEN] || req.body.refreshToken;
153+
const accessToken =
154+
req.cookies?.[COOKIE_NAMES.ACCESS_TOKEN] || req.headers.authorization?.split(' ')[1];
142155

143156
// Revoke refresh token if provided
144157
if (refreshToken) {
@@ -150,6 +163,9 @@ export const logout = asyncHandler(async (req: Request, res: Response) => {
150163
await authService.blacklistAccessToken(accessToken);
151164
}
152165

166+
// Clear auth cookies
167+
clearAuthCookies(res);
168+
153169
sendSuccess(res, null, { message: 'Logged out successfully | تم تسجيل الخروج بنجاح' });
154170
});
155171

@@ -159,7 +175,8 @@ export const logout = asyncHandler(async (req: Request, res: Response) => {
159175
* POST /api/v1/auth/refresh-token
160176
*/
161177
export const refreshToken = asyncHandler(async (req: Request, res: Response) => {
162-
const { refreshToken: token } = req.body;
178+
// Get refresh token from cookies or body (for backward compatibility)
179+
const token = req.cookies?.[COOKIE_NAMES.REFRESH_TOKEN] || req.body.refreshToken;
163180

164181
// Validate refresh token is provided
165182
if (!token || typeof token !== 'string') {
@@ -168,7 +185,18 @@ export const refreshToken = asyncHandler(async (req: Request, res: Response) =>
168185

169186
const tokens = await authService.refreshTokens(token, req.headers['user-agent'], req.ip);
170187

171-
sendSuccess(res, tokens, { message: 'Tokens refreshed successfully | تم تحديث التوكنات بنجاح' });
188+
// Set new httpOnly cookies
189+
setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
190+
191+
sendSuccess(
192+
res,
193+
{
194+
// Include tokens in response for backward compatibility
195+
accessToken: tokens.accessToken,
196+
refreshToken: tokens.refreshToken,
197+
},
198+
{ message: 'Tokens refreshed successfully | تم تحديث التوكنات بنجاح' }
199+
);
172200
});
173201

174202
/**
@@ -367,9 +395,20 @@ export const changePassword = asyncHandler(async (req: Request, res: Response) =
367395
// Generate new tokens
368396
const tokens = await authService.generateTokenPair(user, req.headers['user-agent'], req.ip);
369397

370-
sendSuccess(res, tokens, {
371-
message: 'Password changed successfully | تم تغيير كلمة المرور بنجاح',
372-
});
398+
// Set new httpOnly cookies
399+
setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
400+
401+
sendSuccess(
402+
res,
403+
{
404+
// Include tokens in response for backward compatibility
405+
accessToken: tokens.accessToken,
406+
refreshToken: tokens.refreshToken,
407+
},
408+
{
409+
message: 'Password changed successfully | تم تغيير كلمة المرور بنجاح',
410+
}
411+
);
373412
});
374413

375414
/**
@@ -452,6 +491,9 @@ export const googleAuth = asyncHandler(async (req: Request, res: Response) => {
452491
// Generate tokens
453492
const tokens = await authService.generateTokenPair(user, req.headers['user-agent'], req.ip);
454493

494+
// Set httpOnly cookies
495+
setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
496+
455497
sendSuccess(
456498
res,
457499
{
@@ -463,7 +505,9 @@ export const googleAuth = asyncHandler(async (req: Request, res: Response) => {
463505
avatar: user.avatar,
464506
isEmailVerified: user.isEmailVerified,
465507
},
466-
...tokens,
508+
// Include tokens in response for backward compatibility
509+
accessToken: tokens.accessToken,
510+
refreshToken: tokens.refreshToken,
467511
},
468512
{ message: 'Google sign-in successful | تم تسجيل الدخول بجوجل بنجاح' }
469513
);
@@ -601,6 +645,9 @@ export const githubAuth = asyncHandler(async (req: Request, res: Response) => {
601645
// Generate tokens
602646
const tokens = await authService.generateTokenPair(user, req.headers['user-agent'], req.ip);
603647

648+
// Set httpOnly cookies
649+
setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
650+
604651
sendSuccess(
605652
res,
606653
{
@@ -612,7 +659,9 @@ export const githubAuth = asyncHandler(async (req: Request, res: Response) => {
612659
avatar: user.avatar,
613660
isEmailVerified: user.isEmailVerified,
614661
},
615-
...tokens,
662+
// Include tokens in response for backward compatibility
663+
accessToken: tokens.accessToken,
664+
refreshToken: tokens.refreshToken,
616665
},
617666
{ message: 'GitHub sign-in successful | تم تسجيل الدخول بـ GitHub بنجاح' }
618667
);

0 commit comments

Comments
 (0)