Phase 1: notifyHelper.js → shared/utils/ (4개 서비스 중복 제거) Phase 2: auth.js → shared/middleware/ (system1/system2 통합) Phase 3: errors.js + logger.js → shared/utils/ (system1/system2 통합) Phase 4: DB pool → shared/config/database.js (Group B 4개 서비스 통합) - Docker 빌드 컨텍스트를 루트로 변경 (6개 API 서비스) - 기존 파일은 re-export 패턴으로 consumer 변경 0개 유지 - .dockerignore 추가로 빌드 최적화 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
361 lines
11 KiB
JavaScript
361 lines
11 KiB
JavaScript
/**
|
|
* 통합 인증/인가 미들웨어
|
|
*
|
|
* JWT 토큰 검증 및 권한 체크를 위한 미들웨어 모음
|
|
*
|
|
* @author TK-FB-Project
|
|
* @since 2025-12-11
|
|
*/
|
|
|
|
const jwt = require('jsonwebtoken');
|
|
const { AuthenticationError, ForbiddenError } = require('../utils/errors');
|
|
const logger = require('../utils/logger');
|
|
|
|
// JWT_SECRET: system1/system2는 JWT_SECRET, 나머지는 SSO_JWT_SECRET 사용
|
|
const JWT_SECRET = process.env.JWT_SECRET || process.env.SSO_JWT_SECRET;
|
|
|
|
/**
|
|
* 권한 레벨 계층 구조
|
|
* 숫자가 높을수록 상위 권한
|
|
*/
|
|
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 검증 (SSO 공유 시크릿)
|
|
const decoded = jwt.verify(token, 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.user_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의 user_id로 체크, support_team도 관리자로 인정
|
|
* router.delete('/reports/:id', requireAuth, requireOwnerOrAdmin({
|
|
* resourceField: 'body.user_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
|
|
};
|