From 103c475541ad24eeaa71980ed0cd9142be91eaa2 Mon Sep 17 00:00:00 2001
From: bangdk <bangdk@snatbook.com>
Date: Sat, 9 Nov 2024 06:22:41 +0900
Subject: [PATCH] auto commit

---
 fems-api/package.json                         |   1 +
 .../controllers/app/auth/auth.controller.js   |  13 ++
 fems-api/src/middleware/auth.middleware.js    | 208 ++++++++++++++++--
 fems-api/src/middleware/menu.middleware.js    | 130 +++++++++++
 fems-api/src/routes/registerRoutes.js         |  77 +++++++
 fems-api/src/utils/permissions.js             | 194 ++++++++++++++++
 fems-api/yarn.lock                            |  57 +++++
 7 files changed, 664 insertions(+), 16 deletions(-)
 create mode 100644 fems-api/src/middleware/menu.middleware.js
 create mode 100644 fems-api/src/routes/registerRoutes.js
 create mode 100644 fems-api/src/utils/permissions.js

diff --git a/fems-api/package.json b/fems-api/package.json
index e208996..3898013 100644
--- a/fems-api/package.json
+++ b/fems-api/package.json
@@ -17,6 +17,7 @@
     "dotenv": "^16.0.3",
     "express": "^4.18.2",
     "express-validator": "^7.0.1",
+    "ioredis": "^5.4.1",
     "jsonwebtoken": "^9.0.0",
     "multer": "^1.4.5-lts.1",
     "pg": "^8.11.0",
diff --git a/fems-api/src/controllers/app/auth/auth.controller.js b/fems-api/src/controllers/app/auth/auth.controller.js
index bbfdcad..169f041 100644
--- a/fems-api/src/controllers/app/auth/auth.controller.js
+++ b/fems-api/src/controllers/app/auth/auth.controller.js
@@ -5,6 +5,7 @@ const authService = require("../../../services/auth.service");
 const { body } = require("express-validator");
 const validate = require("../../../middleware/validator.middleware");
 const authMiddleware = require("../../../middleware/auth.middleware");
+const PermissionUtils = require("../../../utils/permission.utils");
 
 router.post(
   "/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;
diff --git a/fems-api/src/middleware/auth.middleware.js b/fems-api/src/middleware/auth.middleware.js
index 3c25f82..d0b439d 100644
--- a/fems-api/src/middleware/auth.middleware.js
+++ b/fems-api/src/middleware/auth.middleware.js
@@ -1,32 +1,208 @@
 // src/middleware/auth.middleware.js
-const jwt = require('jsonwebtoken');
-const config = require('../config/config');
-const { User } = require('../models');
+const jwt = require("jsonwebtoken");
+const { promisify } = require("util");
+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) => {
   try {
-    const authHeader = req.headers.authorization;
-    if (!authHeader || !authHeader.startsWith('Bearer ')) {
-      return res.status(401).json({ message: 'Authentication token is required' });
+    // 토큰 추출
+    const token = extractTokenFromHeader(req);
+    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;
     next();
   } 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
+};
diff --git a/fems-api/src/middleware/menu.middleware.js b/fems-api/src/middleware/menu.middleware.js
new file mode 100644
index 0000000..087ec1e
--- /dev/null
+++ b/fems-api/src/middleware/menu.middleware.js
@@ -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
+};
diff --git a/fems-api/src/routes/registerRoutes.js b/fems-api/src/routes/registerRoutes.js
new file mode 100644
index 0000000..a1db7c5
--- /dev/null
+++ b/fems-api/src/routes/registerRoutes.js
@@ -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;
diff --git a/fems-api/src/utils/permissions.js b/fems-api/src/utils/permissions.js
new file mode 100644
index 0000000..fde31f9
--- /dev/null
+++ b/fems-api/src/utils/permissions.js
@@ -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;
diff --git a/fems-api/yarn.lock b/fems-api/yarn.lock
index a3ad1ee..9dac447 100644
--- a/fems-api/yarn.lock
+++ b/fems-api/yarn.lock
@@ -338,6 +338,11 @@
   resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3"
   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":
   version "1.1.0"
   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"
     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:
   version "4.6.0"
   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"
   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:
   version "2.0.0"
   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"
   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:
   version "1.9.1"
   resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
@@ -2570,11 +2600,21 @@ locate-path@^6.0.0:
   dependencies:
     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:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
   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:
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
@@ -3174,6 +3214,18 @@ readdirp@~3.6.0:
   dependencies:
     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:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
@@ -3433,6 +3485,11 @@ stack-utils@^2.0.3:
   dependencies:
     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:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"