diff --git a/fems-api/src/middleware/cors.middleware.js b/fems-api/src/middleware/cors.middleware.js new file mode 100644 index 0000000..9d980c4 --- /dev/null +++ b/fems-api/src/middleware/cors.middleware.js @@ -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); diff --git a/fems-api/src/middleware/error.middleware.js b/fems-api/src/middleware/error.middleware.js new file mode 100644 index 0000000..4c043c9 --- /dev/null +++ b/fems-api/src/middleware/error.middleware.js @@ -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, +}; diff --git a/fems-api/src/middleware/permission.middleware.js b/fems-api/src/middleware/permission.middleware.js new file mode 100644 index 0000000..b7aa8e5 --- /dev/null +++ b/fems-api/src/middleware/permission.middleware.js @@ -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; diff --git a/fems-api/src/middleware/rateLimit.middleware.js b/fems-api/src/middleware/rateLimit.middleware.js new file mode 100644 index 0000000..7a3e2e8 --- /dev/null +++ b/fems-api/src/middleware/rateLimit.middleware.js @@ -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; diff --git a/fems-api/src/middleware/securityHeaders.middleware.js b/fems-api/src/middleware/securityHeaders.middleware.js new file mode 100644 index 0000000..3b2f59a --- /dev/null +++ b/fems-api/src/middleware/securityHeaders.middleware.js @@ -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; diff --git a/fems-api/src/services/permission.service.js b/fems-api/src/services/permission.service.js new file mode 100644 index 0000000..4c3205a --- /dev/null +++ b/fems-api/src/services/permission.service.js @@ -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; diff --git a/fems-api/src/utils/cache.js b/fems-api/src/utils/cache.js new file mode 100644 index 0000000..3ee94b4 --- /dev/null +++ b/fems-api/src/utils/cache.js @@ -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, +}; diff --git a/fems-api/src/utils/database.js b/fems-api/src/utils/database.js new file mode 100644 index 0000000..5709599 --- /dev/null +++ b/fems-api/src/utils/database.js @@ -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; diff --git a/fems-api/src/utils/errors.js b/fems-api/src/utils/errors.js new file mode 100644 index 0000000..d7941ea --- /dev/null +++ b/fems-api/src/utils/errors.js @@ -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, +}; diff --git a/fems-api/src/utils/logger.js b/fems-api/src/utils/logger.js new file mode 100644 index 0000000..0c1fd11 --- /dev/null +++ b/fems-api/src/utils/logger.js @@ -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, +}; diff --git a/fems-api/src/utils/security.js b/fems-api/src/utils/security.js new file mode 100644 index 0000000..be8ca9e --- /dev/null +++ b/fems-api/src/utils/security.js @@ -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; diff --git a/fems-app/src/app/(equipment)/maintenance/page.tsx b/fems-app/src/app/(equipment)/maintenance/page.tsx index 34637f5..5ecbb4a 100644 --- a/fems-app/src/app/(equipment)/maintenance/page.tsx +++ b/fems-app/src/app/(equipment)/maintenance/page.tsx @@ -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: "완료일",