auto commit

This commit is contained in:
bangdk 2024-11-09 05:59:59 +09:00
parent ecee18ed9e
commit 5a6cfa01b8
12 changed files with 727 additions and 6 deletions

View File

@ -0,0 +1,21 @@
// CORS 설정
// src/middleware/cors.middleware.js
const cors = require("cors");
const config = require("../config");
const corsOptions = {
origin: (origin, callback) => {
const whitelist = config.cors.whitelist;
if (whitelist.indexOf(origin) !== -1 || !origin) {
callback(null, true);
} else {
callback(new Error("Not allowed by CORS"));
}
},
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true,
maxAge: 86400, // 24시간
};
module.exports = cors(corsOptions);

View File

@ -0,0 +1,69 @@
// src/middleware/error.middleware.js
const { logger, auditLogger } = require("../config/logger");
const errorHandler = (err, req, res) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || "error";
// 운영 환경과 개발 환경의 에러 응답 구분
if (process.env.NODE_ENV === "development") {
logger.error(err.stack);
res.status(err.statusCode).json({
status: err.status,
message: err.message,
errors: err.errors,
stack: err.stack,
});
} else {
// 운영 환경에서는 스택 트레이스 제외
logger.error(err.message, {
status: err.status,
statusCode: err.statusCode,
errors: err.errors,
});
// 운영 환경에서는 일반적인 에러 메시지만 전송
if (err.isOperational) {
res.status(err.statusCode).json({
status: err.status,
message: err.message,
errors: err.errors,
});
} else {
// 프로그래밍 에러의 경우 일반적인 에러 메시지 전송
res.status(500).json({
status: "error",
message: "Something went wrong!",
});
}
}
};
// 감사 로그 미들웨어
const auditMiddleware = (req, res, next) => {
const startTime = Date.now();
// 응답이 완료된 후 로깅
res.on("finish", () => {
const duration = Date.now() - startTime;
auditLogger.info({
type: "API_ACCESS",
method: req.method,
path: req.path,
statusCode: res.statusCode,
duration,
userId: req.user?.id,
ip: req.ip,
userAgent: req.headers["user-agent"],
});
});
next();
};
module.exports = {
errorHandler,
auditMiddleware,
};

View File

@ -0,0 +1,29 @@
// src/middleware/permission.middleware.js
const PermissionService = require("../services/permission.service");
const permissionService = new PermissionService();
/**
* 권한 검사 미들웨어
*/
const checkPermission = (resource, action) => async (req, res, next) => {
try {
const hasPermission = await permissionService.checkPermission(
req.user.id,
resource,
action
);
if (!hasPermission) {
return res.status(403).json({
message: "Permission denied",
detail: `Required permission: ${resource}:${action}`,
});
}
next();
} catch (error) {
next(error);
}
};
module.exports = checkPermission;

View File

@ -0,0 +1,24 @@
// Rate Limiting 미들웨어
// src/middleware/rateLimit.middleware.js
const rateLimit = require("express-rate-limit");
const RedisStore = require("rate-limit-redis");
const redis = require("../config/redis");
const createRateLimiter = (options = {}) => {
return rateLimit({
store: new RedisStore({
client: redis,
prefix: "rate-limit:",
}),
windowMs: options.windowMs || 15 * 60 * 1000, // 기본 15분
max: options.max || 100, // 기본 100회
message: {
status: "error",
message: "Too many requests, please try again later.",
},
standardHeaders: true,
legacyHeaders: false,
});
};
module.exports = createRateLimiter;

View File

@ -0,0 +1,35 @@
// 보안 헤더 미들웨어
// src/middleware/securityHeaders.middleware.js
const helmet = require("helmet");
const securityHeaders = helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
},
},
crossOriginEmbedderPolicy: true,
crossOriginOpenerPolicy: true,
crossOriginResourcePolicy: true,
dnsPrefetchControl: true,
expectCt: true,
frameguard: true,
hidePoweredBy: true,
hsts: true,
ieNoOpen: true,
noSniff: true,
originAgentCluster: true,
permittedCrossDomainPolicies: true,
referrerPolicy: true,
xssFilter: true,
});
module.exports = securityHeaders;

View File

@ -0,0 +1,124 @@
// src/services/permission.service.js
const { Role, UserRole, User } = require("../models");
const redis = require("../config/redis");
const logger = require("../utils/logger");
class PermissionService {
constructor() {
// 권한 캐시 TTL (24시간)
this.CACHE_TTL = 24 * 60 * 60;
}
/**
* 사용자의 권한을 검사
*/
async checkPermission(userId, resource, action) {
try {
const permissions = await this.getUserPermissions(userId);
return this.evaluatePermission(permissions, resource, action);
} catch (error) {
logger.error("Permission check failed", {
userId,
resource,
action,
error,
});
return false;
}
}
/**
* 사용자의 모든 권한 조회 (캐시 활용)
*/
async getUserPermissions(userId) {
const cacheKey = `permissions:${userId}`;
// 캐시 확인
let permissions = await redis.get(cacheKey);
if (permissions) {
return JSON.parse(permissions);
}
// DB에서 권한 조회
permissions = await this._loadPermissionsFromDB(userId);
// 캐시 저장
await redis.set(
cacheKey,
JSON.stringify(permissions),
"EX",
this.CACHE_TTL
);
return permissions;
}
/**
* DB에서 사용자 권한 로드
*/
async _loadPermissionsFromDB(userId) {
const user = await User.findByPk(userId, {
include: [
{
model: Role,
through: UserRole,
where: { isActive: true },
},
],
});
if (!user) {
throw new Error("User not found");
}
// 모든 역할의 권한을 병합
return user.Roles.reduce((acc, role) => {
return this._mergePermissions(acc, role.permissions);
}, {});
}
/**
* 권한 평가
*/
evaluatePermission(permissions, resource, action) {
// 슈퍼관리자 권한 체크
if (permissions.superAdmin) {
return true;
}
// 리소스별 권한 체크
const resourcePermissions = permissions[resource];
if (!resourcePermissions) {
return false;
}
// 작업 권한 체크
return resourcePermissions.includes(action);
}
/**
* 권한 병합
*/
_mergePermissions(base, additional) {
const merged = { ...base };
Object.entries(additional).forEach(([resource, actions]) => {
if (!merged[resource]) {
merged[resource] = new Set();
}
actions.forEach((action) => merged[resource].add(action));
});
return merged;
}
/**
* 사용자 권한 캐시 무효화
*/
async invalidateUserPermissions(userId) {
const cacheKey = `permissions:${userId}`;
await redis.del(cacheKey);
}
}
module.exports = PermissionService;

149
fems-api/src/utils/cache.js Normal file
View File

@ -0,0 +1,149 @@
// src/utils/cache.js
const redis = require("../config/redis");
const logger = require("./logger");
const { User } = require("../models");
const { DatabaseUtils } = require("./database");
const { PermissionService } = require("../services/permission.service");
class CacheManager {
constructor(prefix = "app") {
this.prefix = prefix;
this.defaultTTL = 3600; // 1시간
}
/**
* 캐시 생성
*/
_generateKey(key) {
return `${this.prefix}:${key}`;
}
/**
* 캐시 조회 또는 설정
*/
async getOrSet(key, callback, ttl = this.defaultTTL) {
const cacheKey = this._generateKey(key);
try {
// 캐시 확인
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// 캐시가 없는 경우 콜백 실행
const data = await callback();
// 캐시 저장
await redis.set(cacheKey, JSON.stringify(data), "EX", ttl);
return data;
} catch (error) {
logger.error("Cache operation failed", { key, error });
// 캐시 실패 시 콜백 직접 실행
return callback();
}
}
/**
* 캐시 무효화
*/
async invalidate(key) {
const cacheKey = this._generateKey(key);
await redis.del(cacheKey);
}
/**
* 패턴으로 캐시 무효화
*/
async invalidatePattern(pattern) {
const keys = await redis.keys(this._generateKey(pattern));
if (keys.length > 0) {
await redis.del(keys);
}
}
/**
* 해시 캐시 처리
*/
async hashGetOrSet(hashKey, field, callback, ttl = this.defaultTTL) {
const cacheKey = this._generateKey(hashKey);
try {
// 해시 필드 확인
const cached = await redis.hget(cacheKey, field);
if (cached) {
return JSON.parse(cached);
}
// 캐시가 없는 경우 콜백 실행
const data = await callback();
// 해시 필드 저장
await redis.hset(cacheKey, field, JSON.stringify(data));
await redis.expire(cacheKey, ttl);
return data;
} catch (error) {
logger.error("Hash cache operation failed", { hashKey, field, error });
return callback();
}
}
}
// 사용 예시를 위한 서비스 구현
class UserCacheService {
constructor() {
this.cacheManager = new CacheManager("users");
}
/**
* 사용자 정보 조회 (캐시 적용)
*/
async getUserById(userId) {
return this.cacheManager.getOrSet(
`user:${userId}`,
async () => {
const user = await User.findByPk(userId);
return user ? user.toJSON() : null;
},
3600 // 1시간 캐시
);
}
/**
* 사용자의 권한 정보 조회 (해시 캐시 적용)
*/
async getUserPermissions(userId) {
return this.cacheManager.hashGetOrSet(
`permissions`,
userId,
async () => {
const permissions = await PermissionService.getUserPermissions(userId);
return permissions;
},
3600 // 1시간 캐시
);
}
/**
* 사용자 정보 업데이트 캐시 무효화
*/
async updateUser(userId, userData) {
return DatabaseUtils.withTransaction(async (transaction) => {
const user = await User.findByPk(userId);
await user.update(userData, { transaction });
// 관련 캐시 무효화
await this.cacheManager.invalidate(`user:${userId}`);
await this.cacheManager.hashGetOrSet(`permissions`, userId);
return user;
});
}
}
module.exports = {
CacheManager,
UserCacheService,
};

View File

@ -0,0 +1,49 @@
// src/utils/database.js
const { sequelize } = require("../models");
const logger = require("./logger");
class DatabaseUtils {
/**
* 트랜잭션 관리 유틸리티
* @param {Function} callback - 트랜잭션 내에서 실행할 콜백 함수
*/
static async withTransaction(callback) {
const transaction = await sequelize.transaction();
try {
const result = await callback(transaction);
await transaction.commit();
return result;
} catch (error) {
await transaction.rollback();
logger.error("Transaction failed", { error });
throw error;
}
}
/**
* 벌크 연산 유틸리티
* @param {Array} items - 처리할 아이템 배열
* @param {Function} handler - 아이템을 처리할 핸들러 함수
* @param {Object} options - 설정 옵션
*/
static async bulkOperation(items, handler, options = {}) {
const batchSize = options.batchSize || 1000;
const results = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
await this.withTransaction(async (transaction) => {
const batchResults = await Promise.all(
batch.map((item) => handler(item, transaction))
);
results.push(...batchResults);
});
}
return results;
}
}
module.exports = DatabaseUtils;

View File

@ -0,0 +1,37 @@
// src/utils/errors.js
class AppError extends Error {
constructor(message, statusCode = 500, errors = []) {
super(message);
this.statusCode = statusCode;
this.errors = errors;
this.status = `${statusCode}`.startsWith("4") ? "fail" : "error";
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
class ValidationError extends AppError {
constructor(message, errors = []) {
super(message, 400, errors);
}
}
class AuthenticationError extends AppError {
constructor(message) {
super(message, 401);
}
}
class AuthorizationError extends AppError {
constructor(message) {
super(message, 403);
}
}
module.exports = {
AppError,
ValidationError,
AuthenticationError,
AuthorizationError,
};

View File

@ -0,0 +1,59 @@
// src/utils/logger.js
const winston = require("winston");
const { createLogger, format, transports } = winston;
const { combine, timestamp, printf, colorize } = format;
// 로그 포맷 정의
const logFormat = printf(({ level, message, timestamp, ...metadata }) => {
let msg = `${timestamp} [${level}] : ${message}`;
if (Object.keys(metadata).length > 0) {
msg += JSON.stringify(metadata);
}
return msg;
});
// 로거 생성
const logger = createLogger({
format: combine(timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), logFormat),
transports: [
// 콘솔 출력
new transports.Console({
format: combine(colorize(), logFormat),
}),
// 에러 로그 파일
new transports.File({
filename: "logs/error.log",
level: "error",
}),
// 전체 로그 파일
new transports.File({
filename: "logs/combined.log",
}),
],
});
// 감사 로그를 위한 별도 로거
const auditLogger = createLogger({
format: combine(
timestamp(),
printf(({ message, timestamp, ...metadata }) => {
return JSON.stringify({
timestamp,
...message,
...metadata,
});
})
),
transports: [
new transports.File({
filename: "logs/audit.log",
}),
],
});
module.exports = {
logger,
auditLogger,
};

View File

@ -0,0 +1,125 @@
// src/utils/security.js
const crypto = require("crypto");
const jwt = require("jsonwebtoken");
const { AuthenticationError } = require("./errors");
const config = require("../config/config");
const redis = require("../config/redis");
// const logger = require("./logger");
class SecurityUtils {
/**
* JWT 토큰 생성
*/
static generateTokens(payload) {
const accessToken = jwt.sign(payload, config.jwt.secret, {
expiresIn: config.jwt.accessTokenExpiry,
});
const refreshToken = jwt.sign(payload, config.jwt.refreshSecret, {
expiresIn: config.jwt.refreshTokenExpiry,
});
return {
accessToken,
refreshToken,
};
}
/**
* 토큰 검증
*/
static async verifyToken(token, isRefreshToken = false) {
try {
// 토큰 블랙리스트 확인
const isBlacklisted = await this.checkTokenBlacklist(token);
if (isBlacklisted) {
throw new AuthenticationError("Token has been revoked");
}
const secret = isRefreshToken
? config.jwt.refreshSecret
: config.jwt.secret;
return jwt.verify(token, secret);
} catch (error) {
if (error.name === "TokenExpiredError") {
throw new AuthenticationError("Token has expired");
}
throw new AuthenticationError("Invalid token");
}
}
/**
* 토큰 블랙리스트 관리
*/
static async addToBlacklist(token, expirationTime) {
const tokenHash = this.hashToken(token);
await redis.set(`blacklist:${tokenHash}`, "true", "EX", expirationTime);
}
static async checkTokenBlacklist(token) {
const tokenHash = this.hashToken(token);
return await redis.exists(`blacklist:${tokenHash}`);
}
/**
* 토큰 해시화
*/
static hashToken(token) {
return crypto.createHash("sha256").update(token).digest("hex");
}
/**
* API 생성 관리
*/
static generateApiKey() {
return crypto.randomBytes(32).toString("hex");
}
/**
* IP 제한 검사
*/
static checkIpRestriction(allowedIps, currentIp) {
if (!allowedIps || allowedIps.length === 0) {
return true;
}
return allowedIps.some((ip) => {
if (ip.includes("*")) {
const pattern = ip.replace(/\*/g, "\\d+");
const regex = new RegExp(`^${pattern}$`);
return regex.test(currentIp);
}
return ip === currentIp;
});
}
/**
* 데이터 암호화/복호화
*/
static encrypt(text) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(
"aes-256-cbc",
Buffer.from(config.encryption.key),
iv
);
let encrypted = cipher.update(text);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return `${iv.toString("hex")}:${encrypted.toString("hex")}`;
}
static decrypt(text) {
const [ivHex, encryptedHex] = text.split(":");
const iv = Buffer.from(ivHex, "hex");
const encrypted = Buffer.from(encryptedHex, "hex");
const decipher = crypto.createDecipheriv(
"aes-256-cbc",
Buffer.from(config.encryption.key),
iv
);
let decrypted = decipher.update(encrypted);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
}
}
module.exports = SecurityUtils;

View File

@ -95,12 +95,12 @@ const MaintenancePage = () => {
return statusMap[row.original.status] || row.original.status;
},
},
{
accessorKey: "scheduledDate",
header: "예정일",
cell: ({ row }) =>
new Date(row.original.scheduledDate).toLocaleDateString(),
},
// {
// accessorKey: "scheduledDate",
// header: "예정일",
// cell: ({ row }) =>
// new Date(row.original.scheduledDate).toLocaleDateString(),
// },
{
accessorKey: "completionDate",
header: "완료일",