/** * CSRF Protection Middleware * * Cross-Site Request Forgery 방지를 위한 토큰 기반 보호 * * 구현 방식: * 1. 서버에서 CSRF 토큰 생성 및 응답 헤더로 전송 * 2. 클라이언트는 state-changing 요청 시 토큰을 헤더에 포함 * 3. 서버에서 토큰 검증 * * @author TK-FB-Project * @since 2026-02-04 */ const crypto = require('crypto'); const logger = require('../utils/logger'); // 토큰 저장소 (프로덕션에서는 Redis 사용 권장) const tokenStore = new Map(); // 토큰 유효 시간 (1시간) const TOKEN_EXPIRY = 60 * 60 * 1000; // 토큰 정리 주기 (5분) const CLEANUP_INTERVAL = 5 * 60 * 1000; /** * 만료된 토큰 정리 */ const cleanupExpiredTokens = () => { const now = Date.now(); for (const [token, data] of tokenStore.entries()) { if (now > data.expiresAt) { tokenStore.delete(token); } } }; // 주기적 정리 setInterval(cleanupExpiredTokens, CLEANUP_INTERVAL); /** * CSRF 토큰 생성 * * @param {string} sessionId - 세션 ID 또는 사용자 식별자 * @returns {string} 생성된 CSRF 토큰 */ const generateToken = (sessionId) => { const token = crypto.randomBytes(32).toString('hex'); const expiresAt = Date.now() + TOKEN_EXPIRY; tokenStore.set(token, { sessionId, expiresAt, createdAt: Date.now() }); return token; }; /** * CSRF 토큰 검증 * * @param {string} token - 검증할 토큰 * @param {string} sessionId - 세션 ID * @returns {boolean} 유효 여부 */ const validateToken = (token, sessionId) => { if (!token || !tokenStore.has(token)) { return false; } const data = tokenStore.get(token); // 만료 체크 if (Date.now() > data.expiresAt) { tokenStore.delete(token); return false; } // 세션 일치 체크 if (data.sessionId !== sessionId) { return false; } return true; }; /** * CSRF 토큰을 응답 헤더에 설정하는 미들웨어 * * @param {Object} req - Express request * @param {Object} res - Express response * @param {Function} next - Next middleware */ const setCsrfToken = (req, res, next) => { // 세션 ID 또는 사용자 ID 사용 const sessionId = req.user?.user_id?.toString() || req.sessionID || req.ip; // 새 토큰 생성 const token = generateToken(sessionId); // 응답 헤더에 토큰 설정 res.setHeader('X-CSRF-Token', token); // 요청 객체에 저장 (다른 미들웨어에서 사용 가능) req.csrfToken = token; next(); }; /** * CSRF 토큰 검증 미들웨어 * POST, PUT, DELETE, PATCH 요청에 적용 * * @param {Object} options - 옵션 * @param {string[]} options.ignoreMethods - 검증 제외 메서드 (기본: GET, HEAD, OPTIONS) * @param {string[]} options.ignorePaths - 검증 제외 경로 (정규식 패턴 가능) * @returns {Function} Express 미들웨어 */ const verifyCsrfToken = (options = {}) => { const { ignoreMethods = ['GET', 'HEAD', 'OPTIONS'], ignorePaths = ['/api/auth/login', '/api/auth/register', '/api/health'] } = options; return (req, res, next) => { // 제외 메서드 체크 if (ignoreMethods.includes(req.method)) { return next(); } // 제외 경로 체크 for (const pattern of ignorePaths) { if (typeof pattern === 'string' && req.path === pattern) { return next(); } if (pattern instanceof RegExp && pattern.test(req.path)) { return next(); } } // 토큰 추출 (헤더 또는 body에서) const token = req.headers['x-csrf-token'] || req.headers['csrf-token'] || req.body?._csrf || req.query?._csrf; // 세션 ID const sessionId = req.user?.user_id?.toString() || req.sessionID || req.ip; // 토큰 검증 if (!validateToken(token, sessionId)) { logger.warn('CSRF 토큰 검증 실패', { path: req.path, method: req.method, ip: req.ip, hasToken: !!token }); return res.status(403).json({ success: false, error: 'CSRF 토큰이 유효하지 않습니다. 페이지를 새로고침 후 다시 시도해주세요.', code: 'CSRF_TOKEN_INVALID' }); } // 사용된 토큰 제거 (일회성 사용) tokenStore.delete(token); // 새 토큰 발급 const newToken = generateToken(sessionId); res.setHeader('X-CSRF-Token', newToken); req.csrfToken = newToken; next(); }; }; /** * CSRF 토큰 발급 엔드포인트 핸들러 * GET /api/csrf-token */ const getCsrfToken = (req, res) => { const sessionId = req.user?.user_id?.toString() || req.sessionID || req.ip; const token = generateToken(sessionId); res.json({ success: true, csrfToken: token, expiresIn: TOKEN_EXPIRY / 1000 // 초 단위 }); }; module.exports = { generateToken, validateToken, setCsrfToken, verifyCsrfToken, getCsrfToken };