feat: 다수 기능 개선 - 순찰, 출근, 작업분석, 모바일 UI 등
- 순찰/점검 기능 개선 (zone-detail 페이지 추가) - 출근/근태 시스템 개선 (연차 조회, 근무현황) - 작업분석 대분류 그룹화 및 마이그레이션 스크립트 - 모바일 네비게이션 UI 추가 - NAS 배포 도구 및 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
9
deploy/tkfb-package/api.hyungi.net/middlewares/access.js
Normal file
9
deploy/tkfb-package/api.hyungi.net/middlewares/access.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// utils/access.js
|
||||
exports.requireAccess = (...allowed) => {
|
||||
return (req, res, next) => {
|
||||
if (!req.user || !allowed.includes(req.user.access_level)) {
|
||||
return res.status(403).json({ error: '접근 권한이 없습니다' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @deprecated 이 파일은 하위 호환성을 위해 유지됩니다.
|
||||
* 새로운 코드에서는 '../middlewares/auth'의 requireMinLevel을 사용하세요.
|
||||
*
|
||||
* @example
|
||||
* // 이전 방식 (deprecated)
|
||||
* const { requireAccess, ACCESS_LEVELS } = require('../middlewares/accessMiddleware');
|
||||
* router.get('/admin', requireAccess('admin'), handler);
|
||||
*
|
||||
* // 새로운 방식 (권장)
|
||||
* const { requireMinLevel, ACCESS_LEVELS } = require('../middlewares/auth');
|
||||
* router.get('/admin', requireAuth, requireMinLevel('admin'), handler);
|
||||
*/
|
||||
|
||||
const { requireMinLevel, ACCESS_LEVELS } = require('./auth');
|
||||
|
||||
/**
|
||||
* @deprecated requireMinLevel을 사용하세요
|
||||
*/
|
||||
const requireAccess = (requiredLevel) => {
|
||||
return requireMinLevel(requiredLevel);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
requireAccess,
|
||||
ACCESS_LEVELS,
|
||||
// 새로운 API
|
||||
requireMinLevel
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 활동 로깅 미들웨어
|
||||
*
|
||||
* HTTP 요청/응답 활동을 기록하는 미들웨어
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-11
|
||||
*/
|
||||
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* 활동 로거 미들웨어
|
||||
* 모든 HTTP 요청의 시작과 완료를 기록
|
||||
*/
|
||||
const activityLogger = (req, res, next) => {
|
||||
const start = Date.now();
|
||||
|
||||
// 응답 완료 시 로깅
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
const username = req.user?.username || 'anonymous';
|
||||
|
||||
const logData = {
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
statusCode: res.statusCode,
|
||||
duration: `${duration}ms`,
|
||||
ip: req.ip,
|
||||
user: username,
|
||||
userAgent: req.get('User-Agent')
|
||||
};
|
||||
|
||||
// 상태 코드에 따른 로그 레벨 분기
|
||||
if (res.statusCode >= 500) {
|
||||
logger.error('HTTP Request - Server Error', logData);
|
||||
} else if (res.statusCode >= 400) {
|
||||
logger.warn('HTTP Request - Client Error', logData);
|
||||
} else if (res.statusCode >= 300) {
|
||||
logger.info('HTTP Request - Redirect', logData);
|
||||
} else {
|
||||
logger.info('HTTP Request - Success', logData);
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* 민감한 경로 필터 미들웨어
|
||||
* 로그에서 민감한 정보를 제외
|
||||
*/
|
||||
const sensitivePathFilter = (req, res, next) => {
|
||||
const sensitivePaths = [
|
||||
'/api/auth/login',
|
||||
'/api/auth/refresh-token',
|
||||
'/api/users/password'
|
||||
];
|
||||
|
||||
// 민감한 경로의 경우 바디 로깅 스킵
|
||||
if (sensitivePaths.some(path => req.originalUrl.includes(path))) {
|
||||
req.skipBodyLog = true;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
activityLogger,
|
||||
sensitivePathFilter
|
||||
};
|
||||
357
deploy/tkfb-package/api.hyungi.net/middlewares/auth.js
Normal file
357
deploy/tkfb-package/api.hyungi.net/middlewares/auth.js
Normal file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* 통합 인증/인가 미들웨어
|
||||
*
|
||||
* JWT 토큰 검증 및 권한 체크를 위한 미들웨어 모음
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-11
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { AuthenticationError, ForbiddenError } = require('../utils/errors');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* 권한 레벨 계층 구조
|
||||
* 숫자가 높을수록 상위 권한
|
||||
*/
|
||||
const ACCESS_LEVELS = {
|
||||
worker: 1,
|
||||
group_leader: 2,
|
||||
support_team: 3,
|
||||
admin: 4,
|
||||
system: 5
|
||||
};
|
||||
|
||||
/**
|
||||
* JWT 토큰 검증 미들웨어
|
||||
*
|
||||
* Authorization 헤더에서 Bearer 토큰을 추출하고 검증합니다.
|
||||
* 검증 성공 시 req.user에 디코딩된 사용자 정보를 저장합니다.
|
||||
*
|
||||
* @throws {AuthenticationError} 토큰이 없거나 유효하지 않을 때
|
||||
*
|
||||
* @example
|
||||
* router.get('/profile', requireAuth, getProfile);
|
||||
*/
|
||||
const requireAuth = (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers['authorization'];
|
||||
|
||||
if (!authHeader) {
|
||||
logger.warn('인증 실패: Authorization 헤더 없음', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('Authorization 헤더가 필요합니다');
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
logger.warn('인증 실패: Bearer 토큰 누락', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('Bearer 토큰이 필요합니다');
|
||||
}
|
||||
|
||||
// JWT 검증
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
req.user = decoded;
|
||||
|
||||
logger.debug('인증 성공', {
|
||||
user_id: decoded.user_id || decoded.id,
|
||||
username: decoded.username,
|
||||
role: decoded.role,
|
||||
access_level: decoded.access_level
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err.name === 'JsonWebTokenError') {
|
||||
logger.warn('인증 실패: 유효하지 않은 토큰', {
|
||||
error: err.message,
|
||||
path: req.path,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('유효하지 않은 토큰입니다');
|
||||
} else if (err.name === 'TokenExpiredError') {
|
||||
logger.warn('인증 실패: 만료된 토큰', {
|
||||
error: err.message,
|
||||
path: req.path,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('토큰이 만료되었습니다');
|
||||
} else if (err instanceof AuthenticationError) {
|
||||
// 이미 AuthenticationError인 경우 그대로 throw
|
||||
throw err;
|
||||
} else {
|
||||
logger.error('인증 처리 중 예상치 못한 오류', {
|
||||
error: err.message,
|
||||
stack: err.stack
|
||||
});
|
||||
throw new AuthenticationError('인증 처리 중 오류가 발생했습니다');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 역할(들) 권한 체크 미들웨어
|
||||
*
|
||||
* 사용자가 지정된 역할 중 하나를 가지고 있는지 확인합니다.
|
||||
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
|
||||
*
|
||||
* @param {...string} roles - 허용할 역할 목록
|
||||
* @returns {Function} Express 미들웨어 함수
|
||||
* @throws {AuthenticationError} 인증되지 않은 경우
|
||||
* @throws {ForbiddenError} 권한이 없는 경우
|
||||
*
|
||||
* @example
|
||||
* // 단일 역할
|
||||
* router.post('/admin/users', requireAuth, requireRole('admin'), createUser);
|
||||
*
|
||||
* // 여러 역할
|
||||
* router.get('/reports', requireAuth, requireRole('admin', 'support_team'), getReports);
|
||||
*/
|
||||
const requireRole = (...roles) => {
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
logger.warn('권한 체크 실패: 인증되지 않은 요청', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('인증이 필요합니다');
|
||||
}
|
||||
|
||||
const userRole = req.user.role;
|
||||
const userRoleLower = userRole ? userRole.toLowerCase() : '';
|
||||
const rolesLower = roles.map(r => r.toLowerCase());
|
||||
|
||||
if (!rolesLower.includes(userRoleLower)) {
|
||||
logger.warn('권한 체크 실패: 역할 불일치', {
|
||||
user_id: req.user.user_id || req.user.id,
|
||||
username: req.user.username,
|
||||
current_role: userRole,
|
||||
required_roles: roles,
|
||||
path: req.path
|
||||
});
|
||||
throw new ForbiddenError(
|
||||
`이 기능을 사용하려면 ${roles.join(' 또는 ')} 권한이 필요합니다`
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug('역할 권한 확인 성공', {
|
||||
user_id: req.user.user_id || req.user.id,
|
||||
username: req.user.username,
|
||||
role: userRole,
|
||||
required_roles: roles
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 최소 권한 레벨 체크 미들웨어 (계층적)
|
||||
*
|
||||
* 사용자가 요구되는 최소 권한 레벨 이상인지 확인합니다.
|
||||
* worker(1) < group_leader(2) < support_team(3) < admin(4) < system(5)
|
||||
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
|
||||
*
|
||||
* @param {string} minLevel - 최소 권한 레벨 (worker, group_leader, support_team, admin, system)
|
||||
* @returns {Function} Express 미들웨어 함수
|
||||
* @throws {AuthenticationError} 인증되지 않은 경우
|
||||
* @throws {ForbiddenError} 권한이 부족한 경우
|
||||
*
|
||||
* @example
|
||||
* // admin 이상 필요 (admin, system만 허용)
|
||||
* router.delete('/users/:id', requireAuth, requireMinLevel('admin'), deleteUser);
|
||||
*
|
||||
* // group_leader 이상 필요 (group_leader, support_team, admin, system 허용)
|
||||
* router.get('/team-reports', requireAuth, requireMinLevel('group_leader'), getTeamReports);
|
||||
*/
|
||||
const requireMinLevel = (minLevel) => {
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
logger.warn('권한 레벨 체크 실패: 인증되지 않은 요청', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('인증이 필요합니다');
|
||||
}
|
||||
|
||||
const userLevel = ACCESS_LEVELS[req.user.access_level] || 0;
|
||||
const requiredLevel = ACCESS_LEVELS[minLevel] || 999;
|
||||
|
||||
if (userLevel < requiredLevel) {
|
||||
logger.warn('권한 레벨 체크 실패: 권한 부족', {
|
||||
user_id: req.user.user_id || req.user.id,
|
||||
username: req.user.username,
|
||||
current_level: req.user.access_level,
|
||||
current_level_value: userLevel,
|
||||
required_level: minLevel,
|
||||
required_level_value: requiredLevel,
|
||||
path: req.path
|
||||
});
|
||||
throw new ForbiddenError(
|
||||
`이 기능을 사용하려면 ${minLevel} 이상의 권한이 필요합니다 (현재: ${req.user.access_level})`
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug('권한 레벨 확인 성공', {
|
||||
user_id: req.user.user_id || req.user.id,
|
||||
username: req.user.username,
|
||||
access_level: req.user.access_level,
|
||||
required_level: minLevel
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 리소스 소유자 또는 관리자 권한 체크 미들웨어
|
||||
*
|
||||
* 요청한 사용자가 리소스의 소유자이거나 관리자 권한이 있는지 확인합니다.
|
||||
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
|
||||
*
|
||||
* @param {Object} options - 옵션 객체
|
||||
* @param {string} options.resourceField - 리소스 ID를 가져올 req 필드 (예: 'params.user_id', 'body.worker_id')
|
||||
* @param {string} options.userField - 사용자 ID 필드명 (기본값: 'user_id', 'id'도 자동 시도)
|
||||
* @param {string[]} options.adminRoles - 관리자로 인정할 역할들 (기본값: ['admin', 'system'])
|
||||
* @returns {Function} Express 미들웨어 함수
|
||||
* @throws {AuthenticationError} 인증되지 않은 경우
|
||||
* @throws {ForbiddenError} 소유자도 아니고 관리자도 아닌 경우
|
||||
*
|
||||
* @example
|
||||
* // URL 파라미터의 user_id로 체크
|
||||
* router.put('/users/:user_id', requireAuth, requireOwnerOrAdmin({
|
||||
* resourceField: 'params.user_id'
|
||||
* }), updateUser);
|
||||
*
|
||||
* // 요청 body의 worker_id로 체크, support_team도 관리자로 인정
|
||||
* router.delete('/reports/:id', requireAuth, requireOwnerOrAdmin({
|
||||
* resourceField: 'body.worker_id',
|
||||
* adminRoles: ['admin', 'system', 'support_team']
|
||||
* }), deleteReport);
|
||||
*/
|
||||
const requireOwnerOrAdmin = (options = {}) => {
|
||||
const {
|
||||
resourceField = 'params.id',
|
||||
userField = 'user_id',
|
||||
adminRoles = ['admin', 'system']
|
||||
} = options;
|
||||
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
logger.warn('소유자/관리자 체크 실패: 인증되지 않은 요청', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip
|
||||
});
|
||||
throw new AuthenticationError('인증이 필요합니다');
|
||||
}
|
||||
|
||||
// 관리자 권한 체크
|
||||
const userRole = req.user.role;
|
||||
const isAdmin = adminRoles.includes(userRole);
|
||||
|
||||
if (isAdmin) {
|
||||
logger.debug('관리자 권한으로 접근 허용', {
|
||||
user_id: req.user.user_id || req.user.id,
|
||||
username: req.user.username,
|
||||
role: userRole,
|
||||
path: req.path
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
// 리소스 ID 추출
|
||||
const fieldParts = resourceField.split('.');
|
||||
let resourceId = req;
|
||||
for (const part of fieldParts) {
|
||||
resourceId = resourceId[part];
|
||||
if (resourceId === undefined) break;
|
||||
}
|
||||
|
||||
// 사용자 ID (user_id 또는 id)
|
||||
const userId = req.user[userField] || req.user.id || req.user.user_id;
|
||||
|
||||
// 소유자 체크
|
||||
const isOwner = resourceId && String(resourceId) === String(userId);
|
||||
|
||||
if (!isOwner) {
|
||||
logger.warn('소유자/관리자 체크 실패: 권한 부족', {
|
||||
user_id: userId,
|
||||
username: req.user.username,
|
||||
role: userRole,
|
||||
resource_id: resourceId,
|
||||
resource_field: resourceField,
|
||||
is_admin: isAdmin,
|
||||
is_owner: isOwner,
|
||||
path: req.path
|
||||
});
|
||||
throw new ForbiddenError('본인의 리소스이거나 관리자 권한이 필요합니다');
|
||||
}
|
||||
|
||||
logger.debug('리소스 소유자로 접근 허용', {
|
||||
user_id: userId,
|
||||
username: req.user.username,
|
||||
resource_id: resourceId,
|
||||
path: req.path
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 레거시 호환성을 위한 별칭
|
||||
* @deprecated requireAuth를 사용하세요
|
||||
*/
|
||||
const verifyToken = requireAuth;
|
||||
|
||||
/**
|
||||
* 레거시 호환성을 위한 별칭
|
||||
* @deprecated requireRole('admin', 'system')을 사용하세요
|
||||
*/
|
||||
const requireAdmin = requireRole('admin', 'system');
|
||||
|
||||
/**
|
||||
* 레거시 호환성을 위한 별칭
|
||||
* @deprecated requireRole('system')을 사용하세요
|
||||
*/
|
||||
const requireSystem = requireRole('system');
|
||||
|
||||
module.exports = {
|
||||
// 주요 미들웨어
|
||||
requireAuth,
|
||||
requireRole,
|
||||
requireMinLevel,
|
||||
requireOwnerOrAdmin,
|
||||
|
||||
// 레거시 호환성
|
||||
verifyToken,
|
||||
requireAdmin,
|
||||
requireSystem,
|
||||
|
||||
// 상수
|
||||
ACCESS_LEVELS
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @deprecated 이 파일은 하위 호환성을 위해 유지됩니다.
|
||||
* 새로운 코드에서는 './auth'를 직접 import하세요.
|
||||
*
|
||||
* @example
|
||||
* // 이전 방식 (deprecated)
|
||||
* const { verifyToken, requireAdmin } = require('../middlewares/authMiddleware');
|
||||
*
|
||||
* // 새로운 방식 (권장)
|
||||
* const { requireAuth, requireRole } = require('../middlewares/auth');
|
||||
*/
|
||||
|
||||
const {
|
||||
requireAuth,
|
||||
requireRole,
|
||||
requireMinLevel,
|
||||
requireOwnerOrAdmin,
|
||||
verifyToken,
|
||||
requireAdmin,
|
||||
requireSystem,
|
||||
ACCESS_LEVELS
|
||||
} = require('./auth');
|
||||
|
||||
module.exports = {
|
||||
// 레거시 별칭 (하위 호환성)
|
||||
verifyToken,
|
||||
requireAdmin,
|
||||
requireSystem,
|
||||
|
||||
// 새로운 API (권장)
|
||||
requireAuth,
|
||||
requireRole,
|
||||
requireMinLevel,
|
||||
requireOwnerOrAdmin,
|
||||
ACCESS_LEVELS
|
||||
};
|
||||
201
deploy/tkfb-package/api.hyungi.net/middlewares/csrf.js
Normal file
201
deploy/tkfb-package/api.hyungi.net/middlewares/csrf.js
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
102
deploy/tkfb-package/api.hyungi.net/middlewares/errorHandler.js
Normal file
102
deploy/tkfb-package/api.hyungi.net/middlewares/errorHandler.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 에러 핸들러 미들웨어
|
||||
*
|
||||
* 애플리케이션 전역 에러를 처리하는 Express 미들웨어
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-11
|
||||
*/
|
||||
|
||||
const { AppError } = require('../utils/errors');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* 에러 응답 포맷터
|
||||
*/
|
||||
const formatErrorResponse = (error, req) => {
|
||||
const response = {
|
||||
success: false,
|
||||
error: {
|
||||
message: error.message || '알 수 없는 오류가 발생했습니다',
|
||||
code: error.code || 'INTERNAL_ERROR'
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 검증 에러의 경우 상세 정보 포함
|
||||
if (error.details) {
|
||||
response.error.details = error.details;
|
||||
}
|
||||
|
||||
// 개발 환경에서만 스택 트레이스 포함
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
response.error.stack = error.stack;
|
||||
response.request = {
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
ip: req.ip,
|
||||
user: req.user?.username || 'anonymous'
|
||||
};
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 에러 핸들러 미들웨어
|
||||
*/
|
||||
const errorHandler = (error, req, res, next) => {
|
||||
// AppError가 아닌 경우 변환
|
||||
if (!(error instanceof AppError)) {
|
||||
error = new AppError(
|
||||
error.message || '서버 내부 오류가 발생했습니다',
|
||||
500,
|
||||
'INTERNAL_ERROR'
|
||||
);
|
||||
}
|
||||
|
||||
// 로깅
|
||||
if (error.statusCode >= 500) {
|
||||
logger.error(error.message, {
|
||||
code: error.code,
|
||||
stack: error.stack,
|
||||
url: req.originalUrl,
|
||||
method: req.method,
|
||||
user: req.user?.username || 'anonymous'
|
||||
});
|
||||
} else {
|
||||
logger.warn(error.message, {
|
||||
code: error.code,
|
||||
url: req.originalUrl,
|
||||
method: req.method,
|
||||
user: req.user?.username || 'anonymous'
|
||||
});
|
||||
}
|
||||
|
||||
// 응답
|
||||
const response = formatErrorResponse(error, req);
|
||||
res.status(error.statusCode).json(response);
|
||||
};
|
||||
|
||||
/**
|
||||
* 비동기 함수 래퍼 (에러 자동 처리)
|
||||
*/
|
||||
const asyncHandler = (fn) => {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 404 Not Found 핸들러
|
||||
*/
|
||||
const notFoundHandler = (req, res, next) => {
|
||||
const { NotFoundError } = require('../utils/errors');
|
||||
next(new NotFoundError(`경로를 찾을 수 없습니다: ${req.originalUrl}`));
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
errorHandler,
|
||||
asyncHandler,
|
||||
notFoundHandler
|
||||
};
|
||||
Reference in New Issue
Block a user