auto commit
This commit is contained in:
parent
5a6cfa01b8
commit
103c475541
@ -17,6 +17,7 @@
|
|||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-validator": "^7.0.1",
|
"express-validator": "^7.0.1",
|
||||||
|
"ioredis": "^5.4.1",
|
||||||
"jsonwebtoken": "^9.0.0",
|
"jsonwebtoken": "^9.0.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"pg": "^8.11.0",
|
"pg": "^8.11.0",
|
||||||
|
@ -5,6 +5,7 @@ const authService = require("../../../services/auth.service");
|
|||||||
const { body } = require("express-validator");
|
const { body } = require("express-validator");
|
||||||
const validate = require("../../../middleware/validator.middleware");
|
const validate = require("../../../middleware/validator.middleware");
|
||||||
const authMiddleware = require("../../../middleware/auth.middleware");
|
const authMiddleware = require("../../../middleware/auth.middleware");
|
||||||
|
const PermissionUtils = require("../../../utils/permission.utils");
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/login",
|
"/login",
|
||||||
@ -34,4 +35,16 @@ router.post("/logout", authMiddleware, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// src/controllers/app/auth/auth.controller.js
|
||||||
|
router.get("/menu", authMiddleware, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const accessibleMenus = await PermissionUtils.getUserAccessibleMenus(
|
||||||
|
req.user
|
||||||
|
);
|
||||||
|
res.json(accessibleMenus);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
@ -1,32 +1,208 @@
|
|||||||
// src/middleware/auth.middleware.js
|
// src/middleware/auth.middleware.js
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require("jsonwebtoken");
|
||||||
const config = require('../config/config');
|
const { promisify } = require("util");
|
||||||
const { User } = require('../models');
|
const config = require("../config/config");
|
||||||
|
const { User, Role, UserRole } = require("../models");
|
||||||
|
const { AuthenticationError, AuthorizationError } = require("../utils/errors");
|
||||||
|
const logger = require("../utils/logger");
|
||||||
|
const redis = require("../config/redis");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 토큰 검증 및 사용자 인증 미들웨어
|
||||||
|
*/
|
||||||
const authMiddleware = async (req, res, next) => {
|
const authMiddleware = async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const authHeader = req.headers.authorization;
|
// 토큰 추출
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
const token = extractTokenFromHeader(req);
|
||||||
return res.status(401).json({ message: 'Authentication token is required' });
|
if (!token) {
|
||||||
|
throw new AuthenticationError("Authentication token is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = authHeader.split(' ')[1];
|
// 토큰 검증
|
||||||
const decoded = jwt.verify(token, config.jwt.secret);
|
const decoded = await verifyToken(token);
|
||||||
|
|
||||||
const user = await User.findByPk(decoded.id, {
|
// 세션 확인
|
||||||
attributes: { exclude: ['password'] }
|
await validateSession(decoded.id, token);
|
||||||
});
|
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
// 사용자 정보 조회 및 검증
|
||||||
return res.status(401).json({ message: 'User not found or inactive' });
|
const user = await getUserWithRoles(decoded.id);
|
||||||
}
|
validateUser(user);
|
||||||
|
|
||||||
|
// 마지막 활동 시간 업데이트
|
||||||
|
await updateLastActivity(user.id);
|
||||||
|
|
||||||
|
// 요청 객체에 사용자 정보 추가
|
||||||
req.user = user;
|
req.user = user;
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(401).json({ message: 'Invalid token' });
|
handleAuthError(error, res);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = authMiddleware;
|
/**
|
||||||
|
* 특정 권한 필요한 엔드포인트용 미들웨어
|
||||||
|
*/
|
||||||
|
const requireRole = (...roles) => {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ message: "Authentication required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRequiredRole = req.user.Roles.some(
|
||||||
|
(role) => roles.includes(role.name) && role.isActive
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasRequiredRole) {
|
||||||
|
return res.status(403).json({
|
||||||
|
message: `Required role: ${roles.join(" or ")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세부 권한 체크 미들웨어
|
||||||
|
*/
|
||||||
|
const checkPermission = (resource, action) => {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new AuthenticationError("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPermission = await validatePermission(
|
||||||
|
req.user.id,
|
||||||
|
resource,
|
||||||
|
action
|
||||||
|
);
|
||||||
|
if (!hasPermission) {
|
||||||
|
throw new AuthorizationError(
|
||||||
|
`Permission denied: ${resource}:${action}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
handleAuthError(error, res);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 유틸리티 함수들
|
||||||
|
const extractTokenFromHeader = (req) => {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return authHeader.split(" ")[1];
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyToken = async (token) => {
|
||||||
|
try {
|
||||||
|
// 토큰 블랙리스트 체크
|
||||||
|
const isBlacklisted = await redis.get(`blacklist:${token}`);
|
||||||
|
if (isBlacklisted) {
|
||||||
|
throw new AuthenticationError("Token has been revoked");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await promisify(jwt.verify)(token, config.jwt.secret);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === "TokenExpiredError") {
|
||||||
|
throw new AuthenticationError("Token has expired");
|
||||||
|
}
|
||||||
|
throw new AuthenticationError("Invalid token");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateSession = async (userId, token) => {
|
||||||
|
const currentSession = await redis.get(`session:${userId}`);
|
||||||
|
if (currentSession && currentSession !== token) {
|
||||||
|
throw new AuthenticationError("Session has been invalidated");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserWithRoles = async (userId) => {
|
||||||
|
return await User.findByPk(userId, {
|
||||||
|
attributes: { exclude: ["password"] },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Role,
|
||||||
|
through: UserRole,
|
||||||
|
attributes: ["id", "name", "permissions", "isActive"],
|
||||||
|
where: { isActive: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateUser = (user) => {
|
||||||
|
if (!user) {
|
||||||
|
throw new AuthenticationError("User not found");
|
||||||
|
}
|
||||||
|
if (!user.isActive) {
|
||||||
|
throw new AuthenticationError("User account is inactive");
|
||||||
|
}
|
||||||
|
if (user.Roles.length === 0) {
|
||||||
|
throw new AuthorizationError("User has no active roles");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validatePermission = async (userId, resource, action) => {
|
||||||
|
// Redis에서 캐시된 권한 확인
|
||||||
|
const cacheKey = `permissions:${userId}`;
|
||||||
|
let permissions = await redis.get(cacheKey);
|
||||||
|
|
||||||
|
if (!permissions) {
|
||||||
|
// DB에서 권한 조회 및 캐싱
|
||||||
|
const user = await User.findByPk(userId, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Role,
|
||||||
|
attributes: ["permissions"],
|
||||||
|
where: { isActive: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
permissions = user.Roles.reduce((acc, role) => {
|
||||||
|
const rolePerms = role.permissions || {};
|
||||||
|
Object.keys(rolePerms).forEach((res) => {
|
||||||
|
acc[res] = acc[res] || [];
|
||||||
|
acc[res].push(...rolePerms[res]);
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
await redis.set(cacheKey, JSON.stringify(permissions), "EX", 3600); // 1시간 캐시
|
||||||
|
} else {
|
||||||
|
permissions = JSON.parse(permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions[resource]?.includes(action) || false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLastActivity = async (userId) => {
|
||||||
|
await redis.set(`lastActivity:${userId}`, Date.now());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthError = (error, res) => {
|
||||||
|
logger.error("Authentication error", { error });
|
||||||
|
|
||||||
|
if (error instanceof AuthenticationError) {
|
||||||
|
return res.status(401).json({ message: error.message });
|
||||||
|
}
|
||||||
|
if (error instanceof AuthorizationError) {
|
||||||
|
return res.status(403).json({ message: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(500).json({ message: "Internal server error" });
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
authMiddleware,
|
||||||
|
requireRole,
|
||||||
|
checkPermission,
|
||||||
|
validatePermission, // Export for testing
|
||||||
|
};
|
||||||
|
130
fems-api/src/middleware/menu.middleware.js
Normal file
130
fems-api/src/middleware/menu.middleware.js
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
// src/middleware/menu.middleware.js
|
||||||
|
const menuConfig = require("../config/menuConfig");
|
||||||
|
const { AuthorizationError } = require("../utils/errors");
|
||||||
|
const logger = require("../config/logger");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴 접근 권한 검사 미들웨어
|
||||||
|
*/
|
||||||
|
const menuAccessMiddleware = (menuPath) => {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// 메뉴 설정 찾기
|
||||||
|
const menuSettings = findMenuSettings(menuPath);
|
||||||
|
if (!menuSettings) {
|
||||||
|
logger.warn(`Menu configuration not found for path: ${menuPath}`);
|
||||||
|
return res.status(404).json({
|
||||||
|
message: "Menu configuration not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = req;
|
||||||
|
const action = getActionFromMethod(req.method);
|
||||||
|
|
||||||
|
// 권한 설정 확인
|
||||||
|
if (!menuSettings.permissions || !menuSettings.permissions[action]) {
|
||||||
|
logger.warn(
|
||||||
|
`No permission configuration for action: ${action} in menu: ${menuPath}`
|
||||||
|
);
|
||||||
|
return res.status(403).json({
|
||||||
|
message: "Permission configuration not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 권한 확인
|
||||||
|
const allowedRoles = menuSettings.permissions[action];
|
||||||
|
const hasAccess = checkUserAccess(user, allowedRoles);
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
throw new AuthorizationError(
|
||||||
|
`Access denied to menu: ${menuPath}, action: ${action}, user role: ${user.role}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 요청 객체에 메뉴 설정 추가 (후속 처리에서 사용 가능)
|
||||||
|
req.menuSettings = menuSettings;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Menu access check failed", {
|
||||||
|
path: menuPath,
|
||||||
|
userId: req.user?.id,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴 설정 찾기
|
||||||
|
*/
|
||||||
|
const findMenuSettings = (menuPath) => {
|
||||||
|
// admin 또는 app 영역 확인
|
||||||
|
const [area, ...pathParts] = menuPath.split("/").filter(Boolean);
|
||||||
|
let currentConfig = menuConfig[area];
|
||||||
|
|
||||||
|
// 경로를 따라 메뉴 설정 탐색
|
||||||
|
for (const part of pathParts) {
|
||||||
|
if (!currentConfig) return null;
|
||||||
|
|
||||||
|
if (currentConfig[part]) {
|
||||||
|
currentConfig = currentConfig[part];
|
||||||
|
} else if (currentConfig.subMenus?.[part]) {
|
||||||
|
currentConfig = currentConfig.subMenus[part];
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 접근 권한 확인
|
||||||
|
*/
|
||||||
|
const checkUserAccess = (user, allowedRoles) => {
|
||||||
|
// 슈퍼 관리자는 모든 접근 허용
|
||||||
|
if (user.role === "super_admin") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회사 관리자는 자신의 회사 리소스만 접근 가능
|
||||||
|
if (
|
||||||
|
user.role === "company_admin" &&
|
||||||
|
!allowedRoles.includes("company_admin")
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 지점 관리자는 자신의 지점 리소스만 접근 가능
|
||||||
|
if (user.role === "branch_admin" && !allowedRoles.includes("branch_admin")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return allowedRoles.includes(user.role);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP 메소드를 권한 액션으로 변환
|
||||||
|
*/
|
||||||
|
const getActionFromMethod = (method) => {
|
||||||
|
switch (method.toUpperCase()) {
|
||||||
|
case "GET":
|
||||||
|
return "view";
|
||||||
|
case "POST":
|
||||||
|
return "create";
|
||||||
|
case "PUT":
|
||||||
|
case "PATCH":
|
||||||
|
return "update";
|
||||||
|
case "DELETE":
|
||||||
|
return "delete";
|
||||||
|
default:
|
||||||
|
return "view";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
menuAccessMiddleware,
|
||||||
|
findMenuSettings, // Export for testing
|
||||||
|
checkUserAccess, // Export for testing
|
||||||
|
};
|
77
fems-api/src/routes/registerRoutes.js
Normal file
77
fems-api/src/routes/registerRoutes.js
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
// src/routes/registerRoutes.js
|
||||||
|
const express = require("express");
|
||||||
|
const menuConfig = require("../config/menuConfig");
|
||||||
|
const menuAccessMiddleware = require("../middleware/menu.middleware");
|
||||||
|
const logger = require("../config/logger");
|
||||||
|
|
||||||
|
function registerRoutes(app) {
|
||||||
|
// Admin 라우트 등록
|
||||||
|
const adminRouter = express.Router();
|
||||||
|
Object.entries(menuConfig.admin).forEach(([key, config]) => {
|
||||||
|
registerRouteWithPermissions(adminRouter, key, config);
|
||||||
|
});
|
||||||
|
app.use("/api/v1/admin", adminRouter);
|
||||||
|
|
||||||
|
// App 라우트 등록
|
||||||
|
const appRouter = express.Router();
|
||||||
|
Object.entries(menuConfig.app).forEach(([key, config]) => {
|
||||||
|
registerRouteWithPermissions(appRouter, key, config);
|
||||||
|
});
|
||||||
|
app.use("/api/v1/app", appRouter);
|
||||||
|
|
||||||
|
// 등록된 라우트 로깅
|
||||||
|
logRegisteredRoutes(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 권한이 적용된 라우트 등록
|
||||||
|
*/
|
||||||
|
function registerRouteWithPermissions(router, key, config) {
|
||||||
|
const controller = require(`../controllers/${config.path.slice(
|
||||||
|
1
|
||||||
|
)}.controller`);
|
||||||
|
const path = `/${key}`;
|
||||||
|
|
||||||
|
router.use(path, menuAccessMiddleware(config.path), controller);
|
||||||
|
|
||||||
|
// 서브메뉴 등록
|
||||||
|
if (config.subMenus) {
|
||||||
|
Object.entries(config.subMenus).forEach(([subKey, subConfig]) => {
|
||||||
|
const subController = require(`../controllers/${subConfig.path.slice(
|
||||||
|
1
|
||||||
|
)}.controller`);
|
||||||
|
router.use(
|
||||||
|
`${path}/${subKey}`,
|
||||||
|
menuAccessMiddleware(subConfig.path),
|
||||||
|
subController
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 등록된 라우트 로깅
|
||||||
|
*/
|
||||||
|
function logRegisteredRoutes(app) {
|
||||||
|
const routes = [];
|
||||||
|
app._router.stack.forEach((middleware) => {
|
||||||
|
if (middleware.route) {
|
||||||
|
routes.push(
|
||||||
|
`${Object.keys(middleware.route.methods)} ${middleware.route.path}`
|
||||||
|
);
|
||||||
|
} else if (middleware.name === "router") {
|
||||||
|
middleware.handle.stack.forEach((handler) => {
|
||||||
|
if (handler.route) {
|
||||||
|
routes.push(
|
||||||
|
`${Object.keys(handler.route.methods)} ${handler.route.path}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Registered routes:");
|
||||||
|
routes.forEach((route) => logger.info(route));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = registerRoutes;
|
194
fems-api/src/utils/permissions.js
Normal file
194
fems-api/src/utils/permissions.js
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
// src/utils/permissions.js
|
||||||
|
const { Role, UserRole } = require("../models");
|
||||||
|
const menuConfig = require("../config/menuConfig");
|
||||||
|
const redis = require("../config/redis");
|
||||||
|
const logger = require("../config/logger");
|
||||||
|
|
||||||
|
class PermissionUtils {
|
||||||
|
/**
|
||||||
|
* 사용자의 메뉴 권한 검사
|
||||||
|
*/
|
||||||
|
static async checkUserPermissions(user, menuPath, action) {
|
||||||
|
try {
|
||||||
|
// 캐시된 권한 확인
|
||||||
|
const permissions = await this.getUserPermissions(user.id);
|
||||||
|
|
||||||
|
// 메뉴 설정에서 권한 정보 찾기
|
||||||
|
const menuPermissions = this.findMenuPermissions(menuPath);
|
||||||
|
if (!menuPermissions) {
|
||||||
|
logger.warn(`No permission configuration found for menu: ${menuPath}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 역할이 해당 작업의 허용 목록에 있는지 확인
|
||||||
|
const allowedRoles = menuPermissions.permissions[action] || [];
|
||||||
|
return this.hasRequiredRole(permissions.roles, allowedRoles);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Permission check failed", {
|
||||||
|
userId: user.id,
|
||||||
|
menuPath,
|
||||||
|
action,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자의 권한 정보 조회 (캐시 활용)
|
||||||
|
*/
|
||||||
|
static async getUserPermissions(userId) {
|
||||||
|
const cacheKey = `permissions:${userId}`;
|
||||||
|
|
||||||
|
// 캐시 확인
|
||||||
|
let permissions = await redis.get(cacheKey);
|
||||||
|
if (permissions) {
|
||||||
|
return JSON.parse(permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB에서 권한 조회
|
||||||
|
permissions = await this.loadUserPermissions(userId);
|
||||||
|
|
||||||
|
// 캐시 저장 (1시간)
|
||||||
|
await redis.set(cacheKey, JSON.stringify(permissions), "EX", 3600);
|
||||||
|
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB에서 사용자 권한 로드
|
||||||
|
*/
|
||||||
|
static async loadUserPermissions(userId) {
|
||||||
|
const userRoles = await UserRole.findAll({
|
||||||
|
where: { userId },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Role,
|
||||||
|
where: { isActive: true },
|
||||||
|
attributes: ["name", "permissions"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const roles = userRoles.map((ur) => ur.Role.name);
|
||||||
|
const permissions = userRoles.reduce((acc, ur) => {
|
||||||
|
return this.mergePermissions(acc, ur.Role.permissions);
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return { roles, permissions };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴 설정에서 권한 정보 찾기
|
||||||
|
*/
|
||||||
|
static findMenuPermissions(menuPath) {
|
||||||
|
const parts = menuPath.split("/").filter(Boolean);
|
||||||
|
let config = menuConfig;
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (config[part]) {
|
||||||
|
config = config[part];
|
||||||
|
} else if (config.subMenus?.[part]) {
|
||||||
|
config = config.subMenus[part];
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 권한 병합
|
||||||
|
*/
|
||||||
|
static mergePermissions(base, additional) {
|
||||||
|
const merged = { ...base };
|
||||||
|
|
||||||
|
Object.entries(additional || {}).forEach(([key, value]) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
merged[key] = [...new Set([...(merged[key] || []), ...value])];
|
||||||
|
} else if (typeof value === "object") {
|
||||||
|
merged[key] = this.mergePermissions(merged[key] || {}, value);
|
||||||
|
} else {
|
||||||
|
merged[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필요한 역할 확인
|
||||||
|
*/
|
||||||
|
static hasRequiredRole(userRoles, allowedRoles) {
|
||||||
|
return userRoles.some((role) => allowedRoles.includes(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자의 권한 캐시 무효화
|
||||||
|
*/
|
||||||
|
static async invalidateUserPermissions(userId) {
|
||||||
|
const cacheKey = `permissions:${userId}`;
|
||||||
|
await redis.del(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자의 접근 가능한 메뉴 목록 조회
|
||||||
|
*/
|
||||||
|
static async getUserAccessibleMenus(user) {
|
||||||
|
const permissions = await this.getUserPermissions(user.id);
|
||||||
|
return this.filterAccessibleMenus(menuConfig, permissions.roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접근 가능한 메뉴 필터링
|
||||||
|
*/
|
||||||
|
static filterAccessibleMenus(menuConfig, userRoles, parentPath = "") {
|
||||||
|
const accessibleMenus = {};
|
||||||
|
|
||||||
|
Object.entries(menuConfig).forEach(([key, config]) => {
|
||||||
|
const currentPath = `${parentPath}/${key}`;
|
||||||
|
|
||||||
|
// 메뉴에 대한 조회 권한 확인
|
||||||
|
const hasAccess = config.permissions?.view?.some((role) =>
|
||||||
|
userRoles.includes(role)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasAccess) {
|
||||||
|
accessibleMenus[key] = {
|
||||||
|
path: config.path || currentPath,
|
||||||
|
permissions: this.filterUserPermissions(
|
||||||
|
config.permissions,
|
||||||
|
userRoles
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 서브메뉴 처리
|
||||||
|
if (config.subMenus) {
|
||||||
|
accessibleMenus[key].subMenus = this.filterAccessibleMenus(
|
||||||
|
config.subMenus,
|
||||||
|
userRoles,
|
||||||
|
currentPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return accessibleMenus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 권한 필터링
|
||||||
|
*/
|
||||||
|
static filterUserPermissions(permissions, userRoles) {
|
||||||
|
const filtered = {};
|
||||||
|
|
||||||
|
Object.entries(permissions || {}).forEach(([action, allowedRoles]) => {
|
||||||
|
filtered[action] = allowedRoles.some((role) => userRoles.includes(role));
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PermissionUtils;
|
@ -338,6 +338,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3"
|
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3"
|
||||||
integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==
|
integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==
|
||||||
|
|
||||||
|
"@ioredis/commands@^1.1.1":
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11"
|
||||||
|
integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==
|
||||||
|
|
||||||
"@istanbuljs/load-nyc-config@^1.0.0":
|
"@istanbuljs/load-nyc-config@^1.0.0":
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced"
|
resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced"
|
||||||
@ -1073,6 +1078,11 @@ cliui@^8.0.1:
|
|||||||
strip-ansi "^6.0.1"
|
strip-ansi "^6.0.1"
|
||||||
wrap-ansi "^7.0.0"
|
wrap-ansi "^7.0.0"
|
||||||
|
|
||||||
|
cluster-key-slot@^1.1.0:
|
||||||
|
version "1.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac"
|
||||||
|
integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==
|
||||||
|
|
||||||
co@^4.6.0:
|
co@^4.6.0:
|
||||||
version "4.6.0"
|
version "4.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
|
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
|
||||||
@ -1280,6 +1290,11 @@ delayed-stream@~1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
|
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
|
||||||
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
|
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
|
||||||
|
|
||||||
|
denque@^2.1.0:
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1"
|
||||||
|
integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==
|
||||||
|
|
||||||
depd@2.0.0:
|
depd@2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
|
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
|
||||||
@ -1951,6 +1966,21 @@ inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@~2.0.3:
|
|||||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||||
|
|
||||||
|
ioredis@^5.4.1:
|
||||||
|
version "5.4.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.4.1.tgz#1c56b70b759f01465913887375ed809134296f40"
|
||||||
|
integrity sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==
|
||||||
|
dependencies:
|
||||||
|
"@ioredis/commands" "^1.1.1"
|
||||||
|
cluster-key-slot "^1.1.0"
|
||||||
|
debug "^4.3.4"
|
||||||
|
denque "^2.1.0"
|
||||||
|
lodash.defaults "^4.2.0"
|
||||||
|
lodash.isarguments "^3.1.0"
|
||||||
|
redis-errors "^1.2.0"
|
||||||
|
redis-parser "^3.0.0"
|
||||||
|
standard-as-callback "^2.1.0"
|
||||||
|
|
||||||
ipaddr.js@1.9.1:
|
ipaddr.js@1.9.1:
|
||||||
version "1.9.1"
|
version "1.9.1"
|
||||||
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
|
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
|
||||||
@ -2570,11 +2600,21 @@ locate-path@^6.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-locate "^5.0.0"
|
p-locate "^5.0.0"
|
||||||
|
|
||||||
|
lodash.defaults@^4.2.0:
|
||||||
|
version "4.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
|
||||||
|
integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==
|
||||||
|
|
||||||
lodash.includes@^4.3.0:
|
lodash.includes@^4.3.0:
|
||||||
version "4.3.0"
|
version "4.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
|
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
|
||||||
integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==
|
integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==
|
||||||
|
|
||||||
|
lodash.isarguments@^3.1.0:
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a"
|
||||||
|
integrity sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==
|
||||||
|
|
||||||
lodash.isboolean@^3.0.3:
|
lodash.isboolean@^3.0.3:
|
||||||
version "3.0.3"
|
version "3.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
|
resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
|
||||||
@ -3174,6 +3214,18 @@ readdirp@~3.6.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
picomatch "^2.2.1"
|
picomatch "^2.2.1"
|
||||||
|
|
||||||
|
redis-errors@^1.0.0, redis-errors@^1.2.0:
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad"
|
||||||
|
integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==
|
||||||
|
|
||||||
|
redis-parser@^3.0.0:
|
||||||
|
version "3.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4"
|
||||||
|
integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==
|
||||||
|
dependencies:
|
||||||
|
redis-errors "^1.0.0"
|
||||||
|
|
||||||
require-directory@^2.1.1:
|
require-directory@^2.1.1:
|
||||||
version "2.1.1"
|
version "2.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
|
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
|
||||||
@ -3433,6 +3485,11 @@ stack-utils@^2.0.3:
|
|||||||
dependencies:
|
dependencies:
|
||||||
escape-string-regexp "^2.0.0"
|
escape-string-regexp "^2.0.0"
|
||||||
|
|
||||||
|
standard-as-callback@^2.1.0:
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45"
|
||||||
|
integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==
|
||||||
|
|
||||||
statuses@2.0.1:
|
statuses@2.0.1:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
|
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
|
||||||
|
Loading…
Reference in New Issue
Block a user