최초커밋
This commit is contained in:
86
backend-node/src/app.ts
Normal file
86
backend-node/src/app.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import "dotenv/config";
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import helmet from "helmet";
|
||||
import compression from "compression";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import config from "./config/environment";
|
||||
import { logger } from "./utils/logger";
|
||||
import { errorHandler } from "./middleware/errorHandler";
|
||||
|
||||
// 라우터 임포트
|
||||
import authRoutes from "./routes/authRoutes";
|
||||
import adminRoutes from "./routes/adminRoutes";
|
||||
// import userRoutes from './routes/userRoutes';
|
||||
// import menuRoutes from './routes/menuRoutes';
|
||||
|
||||
const app = express();
|
||||
|
||||
// 기본 미들웨어
|
||||
app.use(helmet());
|
||||
app.use(compression());
|
||||
app.use(express.json({ limit: "10mb" }));
|
||||
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
||||
|
||||
// CORS 설정
|
||||
app.use(
|
||||
cors({
|
||||
origin: config.cors.origin,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
|
||||
})
|
||||
);
|
||||
|
||||
// Rate Limiting (개발 환경에서는 완화)
|
||||
const limiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000, // 1분
|
||||
max: config.nodeEnv === "development" ? 1000 : 100, // 개발환경에서는 1000, 운영환경에서는 100
|
||||
message: {
|
||||
error: "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.",
|
||||
},
|
||||
skip: (req) => {
|
||||
// 헬스 체크는 Rate Limiting 제외
|
||||
return req.path === "/health";
|
||||
},
|
||||
});
|
||||
app.use("/api/", limiter);
|
||||
|
||||
// 헬스 체크 엔드포인트
|
||||
app.get("/health", (req, res) => {
|
||||
res.status(200).json({
|
||||
status: "OK",
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: config.nodeEnv,
|
||||
});
|
||||
});
|
||||
|
||||
// API 라우터
|
||||
app.use("/api/auth", authRoutes);
|
||||
app.use("/api/admin", adminRoutes);
|
||||
// app.use('/api/users', userRoutes);
|
||||
// app.use('/api/menus', menuRoutes);
|
||||
|
||||
// 404 핸들러
|
||||
app.use("*", (req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "요청한 리소스를 찾을 수 없습니다.",
|
||||
path: req.originalUrl,
|
||||
});
|
||||
});
|
||||
|
||||
// 에러 핸들러
|
||||
app.use(errorHandler);
|
||||
|
||||
// 서버 시작
|
||||
const PORT = config.port;
|
||||
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`🚀 Server is running on port ${PORT}`);
|
||||
logger.info(`📊 Environment: ${config.nodeEnv}`);
|
||||
logger.info(`🔗 Health check: http://localhost:${PORT}/health`);
|
||||
});
|
||||
|
||||
export default app;
|
||||
159
backend-node/src/controllers/adminController.ts
Normal file
159
backend-node/src/controllers/adminController.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Response } from "express";
|
||||
import { AdminService } from "../services/adminService";
|
||||
import { logger } from "../utils/logger";
|
||||
import { ApiResponse, AuthenticatedRequest } from "../types/auth";
|
||||
|
||||
/**
|
||||
* 관리자 메뉴 목록 조회
|
||||
*/
|
||||
export async function getAdminMenus(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info("=== 관리자 메뉴 목록 조회 시작 ===");
|
||||
|
||||
// 현재 로그인한 사용자의 회사 코드와 로케일 가져오기
|
||||
const userCompanyCode = req.user?.companyCode || "ILSHIN";
|
||||
const userLang = (req.query.userLang as string) || "ko";
|
||||
|
||||
logger.info(`사용자 회사 코드: ${userCompanyCode}`);
|
||||
logger.info(`사용자 로케일: ${userLang}`);
|
||||
|
||||
const paramMap = {
|
||||
userCompanyCode,
|
||||
userLang,
|
||||
SYSTEM_NAME: "PLM",
|
||||
};
|
||||
|
||||
const menuList = await AdminService.getAdminMenuList(paramMap);
|
||||
|
||||
logger.info(`관리자 메뉴 조회 결과: ${menuList.length}개`);
|
||||
if (menuList.length > 0) {
|
||||
logger.info("첫 번째 메뉴:", menuList[0]);
|
||||
}
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
success: true,
|
||||
message: "관리자 메뉴 목록 조회 성공",
|
||||
data: menuList,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("관리자 메뉴 목록 조회 중 오류 발생:", error);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "관리자 메뉴 목록 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "ADMIN_MENU_LIST_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
|
||||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 메뉴 목록 조회
|
||||
*/
|
||||
export async function getUserMenus(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info("=== 사용자 메뉴 목록 조회 시작 ===");
|
||||
|
||||
// 현재 로그인한 사용자의 회사 코드와 로케일 가져오기
|
||||
const userCompanyCode = req.user?.companyCode || "ILSHIN";
|
||||
const userLang = (req.query.userLang as string) || "ko";
|
||||
|
||||
logger.info(`사용자 회사 코드: ${userCompanyCode}`);
|
||||
logger.info(`사용자 로케일: ${userLang}`);
|
||||
|
||||
const paramMap = {
|
||||
userCompanyCode,
|
||||
userLang,
|
||||
SYSTEM_NAME: "PLM",
|
||||
};
|
||||
|
||||
const menuList = await AdminService.getUserMenuList(paramMap);
|
||||
|
||||
logger.info(`사용자 메뉴 조회 결과: ${menuList.length}개`);
|
||||
if (menuList.length > 0) {
|
||||
logger.info("첫 번째 메뉴:", menuList[0]);
|
||||
}
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
success: true,
|
||||
message: "사용자 메뉴 목록 조회 성공",
|
||||
data: menuList,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("사용자 메뉴 목록 조회 중 오류 발생:", error);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "사용자 메뉴 목록 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "USER_MENU_LIST_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
|
||||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 정보 조회
|
||||
*/
|
||||
export async function getMenuInfo(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { menuId } = req.params;
|
||||
logger.info(`=== 메뉴 정보 조회 시작 - menuId: ${menuId} ===`);
|
||||
|
||||
const menuInfo = await AdminService.getMenuInfo(menuId);
|
||||
|
||||
if (!menuInfo) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "메뉴를 찾을 수 없습니다.",
|
||||
error: {
|
||||
code: "MENU_NOT_FOUND",
|
||||
details: `Menu ID: ${menuId}`,
|
||||
},
|
||||
};
|
||||
res.status(404).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse<any> = {
|
||||
success: true,
|
||||
message: "메뉴 정보 조회 성공",
|
||||
data: menuInfo,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("메뉴 정보 조회 중 오류 발생:", error);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "메뉴 정보 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "MENU_INFO_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
|
||||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
297
backend-node/src/controllers/authController.ts
Normal file
297
backend-node/src/controllers/authController.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
// 인증 컨트롤러
|
||||
// 기존 Java ApiLoginController를 Node.js로 포팅
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { AuthService } from "../services/authService";
|
||||
import { JwtUtils } from "../utils/jwtUtils";
|
||||
import { LoginRequest, UserInfo, ApiResponse, PersonBean } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export class AuthController {
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* 기존 Java ApiLoginController.login() 메서드 포팅
|
||||
*/
|
||||
static async login(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { userId, password }: LoginRequest = req.body;
|
||||
const remoteAddr = req.ip || req.connection.remoteAddress || "unknown";
|
||||
|
||||
logger.info(`=== API 로그인 호출됨 ===`);
|
||||
logger.info(`userId: ${userId}`);
|
||||
logger.info(`password: ${password ? "***" : "null"}`);
|
||||
|
||||
// 입력값 검증
|
||||
if (!userId || !password) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "사용자 ID와 비밀번호를 입력해주세요.",
|
||||
error: {
|
||||
code: "INVALID_INPUT",
|
||||
details: "필수 입력값이 누락되었습니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 로그인 프로세스 실행
|
||||
const loginResult = await AuthService.processLogin(
|
||||
userId,
|
||||
password,
|
||||
remoteAddr
|
||||
);
|
||||
|
||||
if (loginResult.success && loginResult.userInfo && loginResult.token) {
|
||||
// 로그인 성공
|
||||
const userInfo: UserInfo = {
|
||||
userId: loginResult.userInfo.userId,
|
||||
userName: loginResult.userInfo.userName || "",
|
||||
deptName: loginResult.userInfo.deptName || "",
|
||||
companyCode: loginResult.userInfo.companyCode || "ILSHIN",
|
||||
};
|
||||
|
||||
logger.info(`=== API 로그인 사용자 정보 디버그 ===`);
|
||||
logger.info(
|
||||
`PersonBean companyCode: ${loginResult.userInfo.companyCode}`
|
||||
);
|
||||
logger.info(`반환할 사용자 정보:`);
|
||||
logger.info(`- userId: ${userInfo.userId}`);
|
||||
logger.info(`- userName: ${userInfo.userName}`);
|
||||
logger.info(`- companyCode: ${userInfo.companyCode}`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "로그인 성공",
|
||||
data: {
|
||||
userInfo,
|
||||
token: loginResult.token,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 로그인 실패
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "로그인 실패",
|
||||
error: {
|
||||
code: "LOGIN_FAILED",
|
||||
details:
|
||||
loginResult.errorReason || "알 수 없는 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`로그인 API 오류: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "서버 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "SERVER_ERROR",
|
||||
details:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* 기존 Java ApiLoginController.logout() 메서드 포팅
|
||||
*/
|
||||
static async logout(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const remoteAddr = req.ip || req.connection.remoteAddress || "unknown";
|
||||
|
||||
// JWT 토큰에서 사용자 정보 추출
|
||||
const authHeader = req.get("Authorization");
|
||||
const token = authHeader && authHeader.split(" ")[1];
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const userInfo = JwtUtils.verifyToken(token);
|
||||
await AuthService.processLogout(userInfo.userId, remoteAddr);
|
||||
} catch (tokenError) {
|
||||
logger.warn(
|
||||
`로그아웃 시 토큰 검증 실패: ${tokenError instanceof Error ? tokenError.message : tokenError}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "로그아웃되었습니다.",
|
||||
data: null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`로그아웃 API 오류: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "로그아웃 처리 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "LOGOUT_ERROR",
|
||||
details:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* 기존 Java ApiLoginController.getCurrentUser() 메서드 포팅
|
||||
*/
|
||||
static async getCurrentUser(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const authHeader = req.get("Authorization");
|
||||
const token = authHeader && authHeader.split(" ")[1];
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증되지 않은 사용자입니다.",
|
||||
error: {
|
||||
code: "NOT_AUTHENTICATED",
|
||||
details: "세션이 만료되었거나 로그인이 필요합니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const userInfo = JwtUtils.verifyToken(token);
|
||||
|
||||
const userInfoResponse: UserInfo = {
|
||||
userId: userInfo.userId,
|
||||
userName: userInfo.userName || "",
|
||||
deptName: userInfo.deptName || "",
|
||||
companyCode: userInfo.companyCode || "ILSHIN",
|
||||
userType: userInfo.userType || "USER",
|
||||
userTypeName: userInfo.userTypeName || "일반사용자",
|
||||
isAdmin:
|
||||
userInfo.userType === "ADMIN" || userInfo.userId === "plm_admin",
|
||||
};
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "사용자 정보 조회 성공",
|
||||
data: userInfoResponse,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`사용자 정보 조회 API 오류: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증되지 않은 사용자입니다.",
|
||||
error: {
|
||||
code: "NOT_AUTHENTICATED",
|
||||
details: "세션이 만료되었거나 로그인이 필요합니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/status
|
||||
* 기존 Java ApiLoginController.checkAuthStatus() 메서드 포팅
|
||||
*/
|
||||
static async checkAuthStatus(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const authHeader = req.get("Authorization");
|
||||
const token = authHeader && authHeader.split(" ")[1];
|
||||
|
||||
if (!token) {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "세션 상태 확인",
|
||||
data: {
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = JwtUtils.validateToken(token);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "세션 상태 확인",
|
||||
data: {
|
||||
isLoggedIn: validation.isValid,
|
||||
isAdmin: false, // TODO: 실제 관리자 권한 확인 로직 추가
|
||||
error: validation.error,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`세션 상태 확인 API 오류: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "세션 상태 확인 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "SESSION_CHECK_ERROR",
|
||||
details:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/refresh
|
||||
* JWT 토큰 갱신 API
|
||||
*/
|
||||
static async refreshToken(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const authHeader = req.get("Authorization");
|
||||
const token = authHeader && authHeader.split(" ")[1];
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "토큰이 필요합니다.",
|
||||
error: {
|
||||
code: "TOKEN_MISSING",
|
||||
details: "인증 토큰이 필요합니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newToken = JwtUtils.refreshToken(token);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "토큰 갱신 성공",
|
||||
data: {
|
||||
token: newToken,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`토큰 갱신 API 오류: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "토큰 갱신에 실패했습니다.",
|
||||
error: {
|
||||
code: "TOKEN_REFRESH_ERROR",
|
||||
details:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
254
backend-node/src/middleware/authMiddleware.ts
Normal file
254
backend-node/src/middleware/authMiddleware.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
// 인증 미들웨어
|
||||
// JWT 토큰 검증 및 사용자 정보 설정
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { JwtUtils } from "../utils/jwtUtils";
|
||||
import { AuthenticatedRequest, PersonBean } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// Express Request 타입 확장
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
ip: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰 검증 미들웨어
|
||||
* 기존 세션 방식과 동일한 효과를 제공
|
||||
*/
|
||||
export const authenticateToken = (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
try {
|
||||
// Authorization 헤더에서 토큰 추출
|
||||
const authHeader = req.get("Authorization");
|
||||
const token = authHeader && authHeader.split(" ")[1]; // Bearer TOKEN
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "TOKEN_MISSING",
|
||||
details: "인증 토큰이 필요합니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// JWT 토큰 검증 및 사용자 정보 추출
|
||||
const userInfo: PersonBean = JwtUtils.verifyToken(token);
|
||||
|
||||
// 요청 객체에 사용자 정보 설정 (기존 PersonBean과 동일)
|
||||
req.user = userInfo;
|
||||
|
||||
// 로그 기록
|
||||
logger.info(`인증 성공: ${userInfo.userId} (${req.ip})`);
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`인증 실패: ${error instanceof Error ? error.message : "Unknown error"} (${req.ip})`
|
||||
);
|
||||
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "INVALID_TOKEN",
|
||||
details:
|
||||
error instanceof Error ? error.message : "토큰 검증에 실패했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 선택적 인증 미들웨어 (토큰이 없어도 통과)
|
||||
* 일부 API에서 사용 (예: 공개 정보 조회)
|
||||
*/
|
||||
export const optionalAuth = (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
try {
|
||||
const authHeader = req.get("Authorization");
|
||||
const token = authHeader && authHeader.split(" ")[1];
|
||||
|
||||
if (token) {
|
||||
const userInfo: PersonBean = JwtUtils.verifyToken(token);
|
||||
req.user = userInfo;
|
||||
logger.info(`선택적 인증 성공: ${userInfo.userId} (${req.ip})`);
|
||||
} else {
|
||||
logger.info(`선택적 인증: 토큰 없음 (${req.ip})`);
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
// 토큰이 있지만 유효하지 않은 경우에도 통과 (선택적 인증)
|
||||
logger.warn(
|
||||
`선택적 인증 실패: ${error instanceof Error ? error.message : "Unknown error"} (${req.ip})`
|
||||
);
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 관리자 권한 확인 미들웨어
|
||||
*/
|
||||
export const requireAdmin = (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
if (!req.user) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "AUTHENTICATION_REQUIRED",
|
||||
details: "인증이 필요합니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 Java 로직과 동일: plm_admin 사용자만 관리자로 인식
|
||||
if (req.user.userId === "plm_admin") {
|
||||
next();
|
||||
} else {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "ADMIN_REQUIRED",
|
||||
details: "관리자 권한이 필요합니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 사용자 또는 관리자 권한 확인 미들웨어
|
||||
*/
|
||||
export const requireUserOrAdmin = (targetUserId: string) => {
|
||||
return (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
if (!req.user) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "AUTHENTICATION_REQUIRED",
|
||||
details: "인증이 필요합니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 본인 또는 관리자인 경우 통과
|
||||
if (req.user.userId === targetUserId || req.user.userId === "plm_admin") {
|
||||
next();
|
||||
} else {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "PERMISSION_DENIED",
|
||||
details: "권한이 없습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 토큰 갱신 미들웨어
|
||||
* 토큰이 곧 만료될 경우 자동으로 갱신
|
||||
*/
|
||||
export const refreshTokenIfNeeded = (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
try {
|
||||
const authHeader = req.get("Authorization");
|
||||
const token = authHeader && authHeader.split(" ")[1];
|
||||
|
||||
if (token) {
|
||||
// 토큰이 1시간 이내에 만료되는지 확인
|
||||
const decoded = JwtUtils.decodeToken(token);
|
||||
if (decoded && decoded.exp) {
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
const timeUntilExpiry = decoded.exp - currentTime;
|
||||
|
||||
// 1시간(3600초) 이내에 만료되는 경우 갱신
|
||||
if (timeUntilExpiry > 0 && timeUntilExpiry < 3600) {
|
||||
const newToken = JwtUtils.refreshToken(token);
|
||||
|
||||
// 새로운 토큰을 응답 헤더에 포함
|
||||
res.setHeader("X-New-Token", newToken);
|
||||
logger.info(`토큰 갱신: ${decoded.userId} (${req.ip})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
// 토큰 갱신 실패해도 요청은 계속 진행
|
||||
logger.warn(
|
||||
`토큰 갱신 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
);
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 인증 상태 확인 미들웨어
|
||||
* 토큰 유효성만 확인하고 사용자 정보는 설정하지 않음
|
||||
*/
|
||||
export const checkAuthStatus = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
try {
|
||||
const authHeader = req.get("Authorization");
|
||||
const token = authHeader && authHeader.split(" ")[1];
|
||||
|
||||
if (!token) {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
isAuthenticated: false,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = JwtUtils.validateToken(token);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
isAuthenticated: validation.isValid,
|
||||
error: validation.error,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`인증 상태 확인 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
isAuthenticated: false,
|
||||
error: "인증 상태 확인 중 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
84
backend-node/src/middleware/errorHandler.ts
Normal file
84
backend-node/src/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 커스텀 에러 클래스
|
||||
export class AppError extends Error {
|
||||
public statusCode: number;
|
||||
public isOperational: boolean;
|
||||
|
||||
constructor(message: string, statusCode: number = 500) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.isOperational = true;
|
||||
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
// 에러 핸들러 미들웨어
|
||||
export const errorHandler = (
|
||||
err: Error | AppError,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
let error = { ...err };
|
||||
error.message = err.message;
|
||||
|
||||
// Prisma 에러 처리
|
||||
if (err.name === "PrismaClientKnownRequestError") {
|
||||
const message = "데이터베이스 요청 오류가 발생했습니다.";
|
||||
error = new AppError(message, 400);
|
||||
}
|
||||
|
||||
// Prisma 유효성 검증 에러
|
||||
if (err.name === "PrismaClientValidationError") {
|
||||
const message = "입력 데이터가 유효하지 않습니다.";
|
||||
error = new AppError(message, 400);
|
||||
}
|
||||
|
||||
// JWT 에러 처리
|
||||
if (err.name === "JsonWebTokenError") {
|
||||
const message = "유효하지 않은 토큰입니다.";
|
||||
error = new AppError(message, 401);
|
||||
}
|
||||
|
||||
if (err.name === "TokenExpiredError") {
|
||||
const message = "토큰이 만료되었습니다.";
|
||||
error = new AppError(message, 401);
|
||||
}
|
||||
|
||||
// 기본 상태 코드 설정
|
||||
const statusCode = (error as AppError).statusCode || 500;
|
||||
const message = error.message || "서버 내부 오류가 발생했습니다.";
|
||||
|
||||
// 에러 로깅
|
||||
logger.error({
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
userAgent: req.get("User-Agent"),
|
||||
});
|
||||
|
||||
// 응답 전송
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: message,
|
||||
...(process.env.NODE_ENV === "development" && { stack: error.stack }),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 404 에러 핸들러
|
||||
export const notFoundHandler = (req: Request, res: Response): void => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: "요청한 리소스를 찾을 수 없습니다.",
|
||||
path: req.originalUrl,
|
||||
},
|
||||
});
|
||||
};
|
||||
19
backend-node/src/routes/adminRoutes.ts
Normal file
19
backend-node/src/routes/adminRoutes.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Router } from "express";
|
||||
import {
|
||||
getAdminMenus,
|
||||
getUserMenus,
|
||||
getMenuInfo,
|
||||
} from "../controllers/adminController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 admin 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 메뉴 관련 API
|
||||
router.get("/menus", getAdminMenus);
|
||||
router.get("/user-menus", getUserMenus);
|
||||
router.get("/menus/:menuId", getMenuInfo);
|
||||
|
||||
export default router;
|
||||
44
backend-node/src/routes/authRoutes.ts
Normal file
44
backend-node/src/routes/authRoutes.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// 인증 API 라우터
|
||||
// Phase 2-1B: 핵심 인증 API 구현
|
||||
|
||||
import { Router } from "express";
|
||||
import { checkAuthStatus } from "../middleware/authMiddleware";
|
||||
import { AuthController } from "../controllers/authController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/auth/status
|
||||
* 인증 상태 확인 API
|
||||
* 기존 Java ApiLoginController.checkAuthStatus() 포팅
|
||||
*/
|
||||
router.get("/status", checkAuthStatus);
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* 로그인 API
|
||||
* 기존 Java ApiLoginController.login() 포팅
|
||||
*/
|
||||
router.post("/login", AuthController.login);
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* 현재 사용자 정보 조회 API
|
||||
* 기존 Java ApiLoginController.getCurrentUser() 포팅
|
||||
*/
|
||||
router.get("/me", AuthController.getCurrentUser);
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* 로그아웃 API
|
||||
* 기존 Java ApiLoginController.logout() 포팅
|
||||
*/
|
||||
router.post("/logout", AuthController.logout);
|
||||
|
||||
/**
|
||||
* POST /api/auth/refresh
|
||||
* JWT 토큰 갱신 API
|
||||
*/
|
||||
router.post("/refresh", AuthController.refreshToken);
|
||||
|
||||
export default router;
|
||||
360
backend-node/src/services/adminService.ts
Normal file
360
backend-node/src/services/adminService.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export class AdminService {
|
||||
/**
|
||||
* 관리자 메뉴 목록 조회
|
||||
*/
|
||||
static async getAdminMenuList(paramMap: any): Promise<any[]> {
|
||||
try {
|
||||
logger.info("AdminService.getAdminMenuList 시작 - 파라미터:", paramMap);
|
||||
|
||||
const { userLang = "ko", SYSTEM_NAME = "PLM" } = paramMap;
|
||||
|
||||
// 기존 Java의 selectAdminMenuList 쿼리를 Prisma로 포팅
|
||||
// WITH RECURSIVE 쿼리를 Prisma의 $queryRaw로 구현
|
||||
const menuList = await prisma.$queryRaw<any[]>`
|
||||
WITH RECURSIVE v_menu(
|
||||
LEVEL,
|
||||
MENU_TYPE,
|
||||
OBJID,
|
||||
PARENT_OBJ_ID,
|
||||
MENU_NAME_KOR,
|
||||
MENU_URL,
|
||||
MENU_DESC,
|
||||
SEQ,
|
||||
WRITER,
|
||||
REGDATE,
|
||||
STATUS,
|
||||
SYSTEM_NAME,
|
||||
COMPANY_CODE,
|
||||
LANG_KEY,
|
||||
LANG_KEY_DESC,
|
||||
PATH,
|
||||
CYCLE,
|
||||
TRANSLATED_NAME,
|
||||
TRANSLATED_DESC
|
||||
) AS (
|
||||
SELECT
|
||||
1 AS LEVEL,
|
||||
MENU.MENU_TYPE,
|
||||
MENU.OBJID::numeric,
|
||||
MENU.PARENT_OBJ_ID,
|
||||
MENU.MENU_NAME_KOR,
|
||||
MENU.MENU_URL,
|
||||
MENU.MENU_DESC,
|
||||
MENU.SEQ,
|
||||
MENU.WRITER,
|
||||
MENU.REGDATE,
|
||||
MENU.STATUS,
|
||||
MENU.SYSTEM_NAME,
|
||||
MENU.COMPANY_CODE,
|
||||
MENU.LANG_KEY,
|
||||
MENU.LANG_KEY_DESC,
|
||||
ARRAY [MENU.OBJID],
|
||||
FALSE,
|
||||
-- 번역된 메뉴명 (우선순위: 회사별 번역 > 공통 번역 > 기본명)
|
||||
COALESCE(
|
||||
(SELECT MLT.lang_text
|
||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
||||
WHERE MLKM.lang_key = MENU.LANG_KEY
|
||||
AND (MLKM.company_code = MENU.COMPANY_CODE OR (MENU.COMPANY_CODE IS NULL AND MLKM.company_code = '*'))
|
||||
AND MLT.lang_code = ${userLang}
|
||||
LIMIT 1),
|
||||
(SELECT MLT.lang_text
|
||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
||||
WHERE MLKM.lang_key = MENU.LANG_KEY
|
||||
AND MLKM.company_code = '*'
|
||||
AND MLT.lang_code = ${userLang}
|
||||
LIMIT 1),
|
||||
MENU.MENU_NAME_KOR
|
||||
),
|
||||
-- 번역된 설명 (우선순위: 회사별 번역 > 공통 번역 > 기본명)
|
||||
COALESCE(
|
||||
(SELECT MLT.lang_text
|
||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
||||
WHERE MLKM.lang_key = MENU.LANG_KEY_DESC
|
||||
AND (MLKM.company_code = MENU.COMPANY_CODE OR (MENU.COMPANY_CODE IS NULL AND MLKM.company_code = '*'))
|
||||
AND MLT.lang_code = ${userLang}
|
||||
LIMIT 1),
|
||||
(SELECT MLT.lang_text
|
||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
||||
WHERE MLKM.lang_key = MENU.LANG_KEY_DESC
|
||||
AND MLKM.company_code = '*'
|
||||
AND MLT.lang_code = ${userLang}
|
||||
LIMIT 1),
|
||||
MENU.MENU_DESC
|
||||
)
|
||||
FROM MENU_INFO MENU
|
||||
WHERE PARENT_OBJ_ID = 0
|
||||
AND MENU_TYPE = 0
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
V_MENU.LEVEL + 1,
|
||||
MENU_SUB.MENU_TYPE,
|
||||
MENU_SUB.OBJID,
|
||||
MENU_SUB.PARENT_OBJ_ID,
|
||||
MENU_SUB.MENU_NAME_KOR,
|
||||
MENU_SUB.MENU_URL,
|
||||
MENU_SUB.MENU_DESC,
|
||||
MENU_SUB.SEQ,
|
||||
MENU_SUB.WRITER,
|
||||
MENU_SUB.REGDATE,
|
||||
MENU_SUB.STATUS,
|
||||
MENU_SUB.SYSTEM_NAME,
|
||||
MENU_SUB.COMPANY_CODE,
|
||||
MENU_SUB.LANG_KEY,
|
||||
MENU_SUB.LANG_KEY_DESC,
|
||||
PATH || MENU_SUB.SEQ::numeric,
|
||||
MENU_SUB.OBJID = ANY(PATH),
|
||||
-- 번역된 메뉴명 (우선순위: 회사별 번역 > 공통 번역 > 기본명)
|
||||
COALESCE(
|
||||
(SELECT MLT.lang_text
|
||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
||||
WHERE MLKM.lang_key = MENU_SUB.LANG_KEY
|
||||
AND (MLKM.company_code = MENU_SUB.COMPANY_CODE OR (MENU_SUB.COMPANY_CODE IS NULL AND MLKM.company_code = '*'))
|
||||
AND MLT.lang_code = ${userLang}
|
||||
LIMIT 1),
|
||||
(SELECT MLT.lang_text
|
||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
||||
WHERE MLKM.lang_key = MENU_SUB.LANG_KEY
|
||||
AND MLKM.company_code = '*'
|
||||
AND MLT.lang_code = ${userLang}
|
||||
LIMIT 1),
|
||||
MENU_SUB.MENU_NAME_KOR
|
||||
),
|
||||
-- 번역된 설명 (우선순위: 회사별 번역 > 공통 번역 > 기본명)
|
||||
COALESCE(
|
||||
(SELECT MLT.lang_text
|
||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
||||
WHERE MLKM.lang_key = MENU_SUB.LANG_KEY_DESC
|
||||
AND (MLKM.company_code = MENU_SUB.COMPANY_CODE OR (MENU_SUB.COMPANY_CODE IS NULL AND MLKM.company_code = '*'))
|
||||
AND MLT.lang_code = ${userLang}
|
||||
LIMIT 1),
|
||||
(SELECT MLT.lang_text
|
||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
||||
WHERE MLKM.lang_key = MENU_SUB.LANG_KEY_DESC
|
||||
AND MLKM.company_code = '*'
|
||||
AND MLT.lang_code = ${userLang}
|
||||
LIMIT 1),
|
||||
MENU_SUB.MENU_DESC
|
||||
)
|
||||
FROM MENU_INFO MENU_SUB
|
||||
JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID
|
||||
WHERE MENU_SUB.OBJID != ANY(V_MENU.PATH)
|
||||
)
|
||||
SELECT
|
||||
LEVEL AS LEV,
|
||||
CASE MENU_TYPE
|
||||
WHEN '0' THEN 'admin'
|
||||
WHEN '1' THEN 'user'
|
||||
ELSE ''
|
||||
END AS MENU_TYPE,
|
||||
A.OBJID,
|
||||
A.PARENT_OBJ_ID,
|
||||
A.MENU_NAME_KOR,
|
||||
LPAD(' ', 3 * (LEVEL - 1)) || A.MENU_NAME_KOR AS LPAD_MENU_NAME_KOR,
|
||||
A.MENU_URL,
|
||||
A.MENU_DESC,
|
||||
A.SEQ,
|
||||
A.WRITER,
|
||||
TO_CHAR(A.REGDATE, 'YYYY-MM-DD') AS REGDATE,
|
||||
A.STATUS,
|
||||
A.COMPANY_CODE,
|
||||
A.LANG_KEY,
|
||||
A.LANG_KEY_DESC,
|
||||
COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME,
|
||||
A.TRANSLATED_NAME,
|
||||
A.TRANSLATED_DESC,
|
||||
CASE UPPER(A.STATUS)
|
||||
WHEN 'ACTIVE' THEN '활성화'
|
||||
WHEN 'INACTIVE' THEN '비활성화'
|
||||
ELSE ''
|
||||
END AS STATUS_TITLE
|
||||
FROM v_menu A
|
||||
LEFT JOIN COMPANY_MNG CM ON A.COMPANY_CODE = CM.COMPANY_CODE
|
||||
WHERE 1 = 1
|
||||
AND (A.SEQ > 1 OR (A.SEQ = 0 AND LEVEL = 1))
|
||||
ORDER BY PATH, SEQ
|
||||
`;
|
||||
|
||||
logger.info(`관리자 메뉴 목록 조회 결과: ${menuList.length}개`);
|
||||
if (menuList.length > 0) {
|
||||
logger.info("첫 번째 메뉴:", menuList[0]);
|
||||
}
|
||||
|
||||
return menuList;
|
||||
} catch (error) {
|
||||
logger.error("AdminService.getAdminMenuList 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 메뉴 목록 조회
|
||||
*/
|
||||
static async getUserMenuList(paramMap: any): Promise<any[]> {
|
||||
try {
|
||||
logger.info("AdminService.getUserMenuList 시작 - 파라미터:", paramMap);
|
||||
|
||||
const { userLang = "ko", SYSTEM_NAME = "PLM" } = paramMap;
|
||||
|
||||
// 기존 Java의 selectUserMenuList 쿼리를 Prisma로 포팅
|
||||
const menuList = await prisma.$queryRaw<any[]>`
|
||||
WITH RECURSIVE v_menu(
|
||||
LEVEL,
|
||||
MENU_TYPE,
|
||||
OBJID,
|
||||
PARENT_OBJ_ID,
|
||||
MENU_NAME_KOR,
|
||||
MENU_URL,
|
||||
MENU_DESC,
|
||||
SEQ,
|
||||
WRITER,
|
||||
REGDATE,
|
||||
STATUS,
|
||||
COMPANY_CODE,
|
||||
LANG_KEY,
|
||||
LANG_KEY_DESC,
|
||||
PATH,
|
||||
CYCLE
|
||||
) AS (
|
||||
SELECT
|
||||
1 AS LEVEL,
|
||||
MENU_TYPE,
|
||||
OBJID::numeric,
|
||||
PARENT_OBJ_ID,
|
||||
MENU_NAME_KOR,
|
||||
MENU_URL,
|
||||
MENU_DESC,
|
||||
SEQ,
|
||||
WRITER,
|
||||
REGDATE,
|
||||
STATUS,
|
||||
COMPANY_CODE,
|
||||
LANG_KEY,
|
||||
LANG_KEY_DESC,
|
||||
ARRAY [MENU.OBJID],
|
||||
FALSE
|
||||
FROM MENU_INFO MENU
|
||||
WHERE PARENT_OBJ_ID = 0
|
||||
AND MENU_TYPE = 1
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
V_MENU.LEVEL + 1,
|
||||
MENU_SUB.MENU_TYPE,
|
||||
MENU_SUB.OBJID,
|
||||
MENU_SUB.PARENT_OBJ_ID,
|
||||
MENU_SUB.MENU_NAME_KOR,
|
||||
MENU_SUB.MENU_URL,
|
||||
MENU_SUB.MENU_DESC,
|
||||
MENU_SUB.SEQ,
|
||||
MENU_SUB.WRITER,
|
||||
MENU_SUB.REGDATE,
|
||||
MENU_SUB.STATUS,
|
||||
MENU_SUB.COMPANY_CODE,
|
||||
MENU_SUB.LANG_KEY,
|
||||
MENU_SUB.LANG_KEY_DESC,
|
||||
PATH || MENU_SUB.SEQ::numeric,
|
||||
MENU_SUB.OBJID = ANY(PATH)
|
||||
FROM MENU_INFO MENU_SUB
|
||||
JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID
|
||||
WHERE 1 = 1
|
||||
)
|
||||
SELECT
|
||||
LEVEL AS LEV,
|
||||
CASE MENU_TYPE
|
||||
WHEN '0' THEN 'admin'
|
||||
WHEN '1' THEN 'user'
|
||||
ELSE ''
|
||||
END AS MENU_TYPE,
|
||||
A.OBJID,
|
||||
A.PARENT_OBJ_ID,
|
||||
A.MENU_NAME_KOR,
|
||||
LPAD(' ', 3 * (LEVEL - 1)) || A.MENU_NAME_KOR AS LPAD_MENU_NAME_KOR,
|
||||
A.MENU_URL,
|
||||
A.MENU_DESC,
|
||||
A.SEQ,
|
||||
A.WRITER,
|
||||
TO_CHAR(A.REGDATE, 'YYYY-MM-DD') AS REGDATE,
|
||||
A.STATUS,
|
||||
A.COMPANY_CODE,
|
||||
A.LANG_KEY,
|
||||
A.LANG_KEY_DESC,
|
||||
COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME,
|
||||
-- 번역된 메뉴명 (우선순위: 번역 > 기본명)
|
||||
COALESCE(MLT_NAME.lang_text, A.MENU_NAME_KOR) AS TRANSLATED_NAME,
|
||||
-- 번역된 설명 (우선순위: 번역 > 기본명)
|
||||
COALESCE(MLT_DESC.lang_text, A.MENU_DESC) AS TRANSLATED_DESC,
|
||||
CASE UPPER(A.STATUS)
|
||||
WHEN 'ACTIVE' THEN '활성화'
|
||||
WHEN 'INACTIVE' THEN '비활성화'
|
||||
ELSE ''
|
||||
END AS STATUS_TITLE
|
||||
FROM v_menu A
|
||||
LEFT JOIN COMPANY_MNG CM ON A.COMPANY_CODE = CM.COMPANY_CODE
|
||||
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_NAME ON A.LANG_KEY = MLKM_NAME.lang_key
|
||||
LEFT JOIN MULTI_LANG_TEXT MLT_NAME ON MLKM_NAME.key_id = MLT_NAME.key_id AND MLT_NAME.lang_code = ${userLang}
|
||||
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_DESC ON A.LANG_KEY_DESC = MLKM_DESC.lang_key
|
||||
LEFT JOIN MULTI_LANG_TEXT MLT_DESC ON MLKM_DESC.key_id = MLT_DESC.key_id AND MLT_DESC.lang_code = ${userLang}
|
||||
WHERE 1 = 1
|
||||
AND (A.SEQ > 1 OR (A.SEQ = 0 AND LEVEL = 1))
|
||||
ORDER BY PATH, SEQ
|
||||
`;
|
||||
|
||||
logger.info(`사용자 메뉴 목록 조회 결과: ${menuList.length}개`);
|
||||
if (menuList.length > 0) {
|
||||
logger.info("첫 번째 메뉴:", menuList[0]);
|
||||
}
|
||||
|
||||
return menuList;
|
||||
} catch (error) {
|
||||
logger.error("AdminService.getUserMenuList 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 정보 조회
|
||||
*/
|
||||
static async getMenuInfo(menuId: string): Promise<any> {
|
||||
try {
|
||||
logger.info(`AdminService.getMenuInfo 시작 - menuId: ${menuId}`);
|
||||
|
||||
// menu_info 모델이 @@ignore로 설정되어 있으므로 $queryRaw 사용
|
||||
const menuInfo = await prisma.$queryRaw<any[]>`
|
||||
SELECT
|
||||
MI.*,
|
||||
COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME
|
||||
FROM MENU_INFO MI
|
||||
LEFT JOIN COMPANY_MNG CM ON MI.COMPANY_CODE = CM.COMPANY_CODE
|
||||
WHERE MI.OBJID = ${parseInt(menuId)}::numeric
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (!menuInfo || menuInfo.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info("메뉴 정보 조회 결과:", menuInfo[0]);
|
||||
return menuInfo[0];
|
||||
} catch (error) {
|
||||
logger.error("AdminService.getMenuInfo 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
309
backend-node/src/services/authService.ts
Normal file
309
backend-node/src/services/authService.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
// 인증 서비스
|
||||
// 기존 Java LoginService를 Node.js로 포팅
|
||||
|
||||
import prisma from "../config/database";
|
||||
import { PasswordUtils } from "../utils/passwordUtils";
|
||||
import { JwtUtils } from "../utils/jwtUtils";
|
||||
import { PersonBean, LoginResult, LoginLogData } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export class AuthService {
|
||||
/**
|
||||
* 기존 Java LoginService.loginPwdCheck() 메서드 포팅
|
||||
* 로그인을 시도하여 결과를 return 한다.
|
||||
*/
|
||||
static async loginPwdCheck(
|
||||
userId: string,
|
||||
password: string
|
||||
): Promise<LoginResult> {
|
||||
try {
|
||||
// 사용자 비밀번호 조회 (기존 login.getUserPassword 쿼리 포팅)
|
||||
const userInfo = await prisma.user_info.findUnique({
|
||||
where: {
|
||||
user_id: userId,
|
||||
},
|
||||
select: {
|
||||
user_password: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (userInfo && userInfo.user_password) {
|
||||
const dbPassword = userInfo.user_password;
|
||||
|
||||
logger.info(`로그인 시도: ${userId}`);
|
||||
logger.debug(`DB 비밀번호: ${dbPassword}, 입력 비밀번호: ${password}`);
|
||||
|
||||
// 마스터 패스워드 체크 (기존 Java 로직과 동일)
|
||||
if (password === "qlalfqjsgh11") {
|
||||
logger.info(`마스터 패스워드로 로그인 성공: ${userId}`);
|
||||
return {
|
||||
loginResult: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 비밀번호 검증 (기존 EncryptUtil 로직 사용)
|
||||
if (PasswordUtils.matches(password, dbPassword)) {
|
||||
logger.info(`비밀번호 일치로 로그인 성공: ${userId}`);
|
||||
return {
|
||||
loginResult: true,
|
||||
};
|
||||
} else {
|
||||
logger.warn(`비밀번호 불일치로 로그인 실패: ${userId}`);
|
||||
return {
|
||||
loginResult: false,
|
||||
errorReason: "패스워드가 일치하지 않습니다.",
|
||||
};
|
||||
}
|
||||
} else {
|
||||
logger.warn(`사용자가 존재하지 않음: ${userId}`);
|
||||
return {
|
||||
loginResult: false,
|
||||
errorReason: "사용자가 존재하지 않습니다.",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`로그인 검증 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
return {
|
||||
loginResult: false,
|
||||
errorReason: "로그인 처리 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 Java LoginService.insertLoginAccessLog() 메서드 포팅
|
||||
* 로그인 로그를 기록한다.
|
||||
*/
|
||||
static async insertLoginAccessLog(logData: LoginLogData): Promise<void> {
|
||||
try {
|
||||
// 기존 login.insertLoginAccessLog 쿼리 포팅
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO LOGIN_ACCESS_LOG(
|
||||
LOG_TIME,
|
||||
SYSTEM_NAME,
|
||||
USER_ID,
|
||||
LOGIN_RESULT,
|
||||
ERROR_MESSAGE,
|
||||
REMOTE_ADDR,
|
||||
RECPTN_DT,
|
||||
RECPTN_RSLT_DTL,
|
||||
RECPTN_RSLT,
|
||||
RECPTN_RSLT_CD
|
||||
) VALUES (
|
||||
now(),
|
||||
${logData.systemName},
|
||||
UPPER(${logData.userId}),
|
||||
${logData.loginResult},
|
||||
${logData.errorMessage || null},
|
||||
${logData.remoteAddr},
|
||||
${logData.recptnDt || null},
|
||||
${logData.recptnRsltDtl || null},
|
||||
${logData.recptnRslt || null},
|
||||
${logData.recptnRsltCd || null}
|
||||
)
|
||||
`;
|
||||
|
||||
logger.info(
|
||||
`로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`로그인 로그 기록 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
// 로그 기록 실패는 로그인 프로세스를 중단하지 않음
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 Java SessionManager.setSessionManage() 메서드 포팅
|
||||
* 로그인 성공 시 사용자 정보를 조회하여 PersonBean 형태로 반환
|
||||
*/
|
||||
static async getUserInfo(userId: string): Promise<PersonBean | null> {
|
||||
try {
|
||||
// 기존 login.getUserInfo 쿼리 포팅
|
||||
const userInfo = await prisma.user_info.findUnique({
|
||||
where: {
|
||||
user_id: userId,
|
||||
},
|
||||
select: {
|
||||
sabun: true,
|
||||
user_id: true,
|
||||
user_name: true,
|
||||
user_name_eng: true,
|
||||
user_name_cn: true,
|
||||
dept_code: true,
|
||||
dept_name: true,
|
||||
position_code: true,
|
||||
position_name: true,
|
||||
email: true,
|
||||
tel: true,
|
||||
cell_phone: true,
|
||||
user_type: true,
|
||||
user_type_name: true,
|
||||
partner_objid: true,
|
||||
company_code: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 권한 정보 조회 (기존 Java 로직과 동일)
|
||||
const authInfo = await prisma.$queryRaw<Array<{ auth_name: string }>>`
|
||||
SELECT ARRAY_TO_STRING(ARRAY_AGG(AM.AUTH_NAME), ',') AS AUTH_NAME
|
||||
FROM AUTHORITY_MASTER AM, AUTHORITY_SUB_USER ASU
|
||||
WHERE AM.OBJID = ASU.MASTER_OBJID
|
||||
AND ASU.USER_ID = ${userId}
|
||||
GROUP BY ASU.USER_ID
|
||||
`;
|
||||
|
||||
// 회사 정보 조회 (기존 Java 로직과 동일)
|
||||
const companyInfo = await prisma.$queryRaw<
|
||||
Array<{ company_name: string }>
|
||||
>`
|
||||
SELECT COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME
|
||||
FROM COMPANY_MNG CM
|
||||
WHERE CM.COMPANY_CODE = ${userInfo.company_code || "ILSHIN"}
|
||||
`;
|
||||
|
||||
// PersonBean 형태로 변환 (null 값을 undefined로 변환)
|
||||
const personBean: PersonBean = {
|
||||
userId: userInfo.user_id,
|
||||
userName: userInfo.user_name || "",
|
||||
userNameEng: userInfo.user_name_eng || undefined,
|
||||
userNameCn: userInfo.user_name_cn || undefined,
|
||||
deptCode: userInfo.dept_code || undefined,
|
||||
deptName: userInfo.dept_name || undefined,
|
||||
positionCode: userInfo.position_code || undefined,
|
||||
positionName: userInfo.position_name || undefined,
|
||||
email: userInfo.email || undefined,
|
||||
tel: userInfo.tel || undefined,
|
||||
cellPhone: userInfo.cell_phone || undefined,
|
||||
userType: userInfo.user_type || undefined,
|
||||
userTypeName: userInfo.user_type_name || undefined,
|
||||
partnerObjid: userInfo.partner_objid || undefined,
|
||||
authName: authInfo.length > 0 ? authInfo[0].auth_name : undefined,
|
||||
companyCode: userInfo.company_code || "ILSHIN",
|
||||
};
|
||||
|
||||
logger.info(`사용자 정보 조회 완료: ${userId}`);
|
||||
return personBean;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`사용자 정보 조회 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰으로 사용자 정보 조회
|
||||
*/
|
||||
static async getUserInfoFromToken(token: string): Promise<PersonBean | null> {
|
||||
try {
|
||||
const userInfo = JwtUtils.verifyToken(token);
|
||||
return userInfo;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`토큰에서 사용자 정보 조회 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 프로세스 전체 처리
|
||||
*/
|
||||
static async processLogin(
|
||||
userId: string,
|
||||
password: string,
|
||||
remoteAddr: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
userInfo?: PersonBean;
|
||||
token?: string;
|
||||
errorReason?: string;
|
||||
}> {
|
||||
try {
|
||||
// 1. 로그인 검증
|
||||
const loginResult = await this.loginPwdCheck(userId, password);
|
||||
|
||||
// 2. 로그 기록
|
||||
const logData: LoginLogData = {
|
||||
systemName: "PMS",
|
||||
userId: userId,
|
||||
loginResult: loginResult.loginResult,
|
||||
errorMessage: loginResult.errorReason,
|
||||
remoteAddr: remoteAddr,
|
||||
};
|
||||
|
||||
await this.insertLoginAccessLog(logData);
|
||||
|
||||
if (loginResult.loginResult) {
|
||||
// 3. 사용자 정보 조회
|
||||
const userInfo = await this.getUserInfo(userId);
|
||||
if (!userInfo) {
|
||||
return {
|
||||
success: false,
|
||||
errorReason: "사용자 정보를 조회할 수 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
// 4. JWT 토큰 생성
|
||||
const token = JwtUtils.generateToken(userInfo);
|
||||
|
||||
logger.info(`로그인 성공: ${userId} (${remoteAddr})`);
|
||||
return {
|
||||
success: true,
|
||||
userInfo,
|
||||
token,
|
||||
};
|
||||
} else {
|
||||
logger.warn(
|
||||
`로그인 실패: ${userId} - ${loginResult.errorReason} (${remoteAddr})`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
errorReason: loginResult.errorReason,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`로그인 프로세스 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
errorReason: "로그인 처리 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 프로세스 처리
|
||||
*/
|
||||
static async processLogout(
|
||||
userId: string,
|
||||
remoteAddr: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 로그아웃 로그 기록
|
||||
const logData: LoginLogData = {
|
||||
systemName: "PMS",
|
||||
userId: userId,
|
||||
loginResult: false,
|
||||
errorMessage: "로그아웃",
|
||||
remoteAddr: remoteAddr,
|
||||
};
|
||||
|
||||
await this.insertLoginAccessLog(logData);
|
||||
logger.info(`로그아웃 완료: ${userId} (${remoteAddr})`);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`로그아웃 처리 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
192
backend-node/src/test/auth-test.ts
Normal file
192
backend-node/src/test/auth-test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
// 인증 시스템 테스트 파일
|
||||
// Phase 2-1A 구현 내용 테스트
|
||||
|
||||
import { PasswordUtils } from "../utils/passwordUtils";
|
||||
import { JwtUtils } from "../utils/jwtUtils";
|
||||
import { PersonBean } from "../types/auth";
|
||||
|
||||
// 테스트용 사용자 정보
|
||||
const testUserInfo: PersonBean = {
|
||||
userId: "test_user",
|
||||
userName: "테스트 사용자",
|
||||
deptName: "개발팀",
|
||||
companyCode: "ILSHIN",
|
||||
email: "test@ilshin.com",
|
||||
tel: "02-1234-5678",
|
||||
cellPhone: "010-1234-5678",
|
||||
};
|
||||
|
||||
/**
|
||||
* 비밀번호 암호화 테스트
|
||||
*/
|
||||
function testPasswordUtils(): void {
|
||||
console.log("\n=== 비밀번호 암호화 테스트 ===");
|
||||
|
||||
const testPassword = "test1234";
|
||||
|
||||
try {
|
||||
// 암호화 테스트
|
||||
const encrypted = PasswordUtils.encrypt(testPassword);
|
||||
console.log("원본 비밀번호:", testPassword);
|
||||
console.log("암호화 결과:", encrypted);
|
||||
|
||||
// 복호화 테스트
|
||||
const decrypted = PasswordUtils.decrypt(encrypted);
|
||||
console.log("복호화 결과:", decrypted);
|
||||
console.log("복호화 성공:", testPassword === decrypted);
|
||||
|
||||
// 비밀번호 검증 테스트
|
||||
const isMatch = PasswordUtils.matches(testPassword, encrypted);
|
||||
console.log("비밀번호 검증:", isMatch);
|
||||
|
||||
// SHA256 해시 테스트
|
||||
const sha256Hash = PasswordUtils.encryptSha256(testPassword);
|
||||
console.log("SHA256 해시:", sha256Hash);
|
||||
|
||||
// 마스터 패스워드 테스트
|
||||
const masterPasswordMatch = PasswordUtils.matches(
|
||||
"qlalfqjsgh11",
|
||||
"any_encrypted_password"
|
||||
);
|
||||
console.log("마스터 패스워드 검증:", masterPasswordMatch);
|
||||
} catch (error) {
|
||||
console.error("비밀번호 암호화 테스트 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰 테스트
|
||||
*/
|
||||
function testJwtUtils(): void {
|
||||
console.log("\n=== JWT 토큰 테스트 ===");
|
||||
|
||||
try {
|
||||
// 토큰 생성 테스트
|
||||
const token = JwtUtils.generateToken(testUserInfo);
|
||||
console.log("생성된 토큰:", token);
|
||||
|
||||
// 토큰 디코드 테스트
|
||||
const decoded = JwtUtils.decodeToken(token);
|
||||
console.log("디코드된 페이로드:", decoded);
|
||||
|
||||
// 토큰 검증 테스트
|
||||
const verified = JwtUtils.verifyToken(token);
|
||||
console.log("검증된 사용자 정보:", verified);
|
||||
console.log("검증 성공:", testUserInfo.userId === verified.userId);
|
||||
|
||||
// 토큰 만료 확인 테스트
|
||||
const isExpired = JwtUtils.isTokenExpired(token);
|
||||
console.log("토큰 만료 여부:", isExpired);
|
||||
|
||||
// 사용자 ID 추출 테스트
|
||||
const userId = JwtUtils.getUserIdFromToken(token);
|
||||
console.log("토큰에서 추출한 사용자 ID:", userId);
|
||||
|
||||
// 토큰 유효성 검사 테스트
|
||||
const validation = JwtUtils.validateToken(token);
|
||||
console.log("토큰 유효성 검사:", validation);
|
||||
|
||||
// 토큰 갱신 테스트
|
||||
const refreshedToken = JwtUtils.refreshToken(token);
|
||||
console.log("갱신된 토큰:", refreshedToken);
|
||||
console.log("토큰 갱신 성공:", token !== refreshedToken);
|
||||
} catch (error) {
|
||||
console.error("JWT 토큰 테스트 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 잘못된 토큰 테스트
|
||||
*/
|
||||
function testInvalidToken(): void {
|
||||
console.log("\n=== 잘못된 토큰 테스트 ===");
|
||||
|
||||
try {
|
||||
// 잘못된 토큰으로 검증 시도
|
||||
const invalidToken = "invalid.token.here";
|
||||
JwtUtils.verifyToken(invalidToken);
|
||||
} catch (error) {
|
||||
console.log(
|
||||
"예상된 오류 (잘못된 토큰):",
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 만료된 토큰 테스트 (1초 후 만료되는 토큰 생성)
|
||||
const shortLivedToken = JwtUtils.generateToken(testUserInfo);
|
||||
// 실제로는 시간을 기다려야 하지만, 여기서는 검증 로직만 테스트
|
||||
console.log("단기 토큰 생성 성공");
|
||||
} catch (error) {
|
||||
console.error("단기 토큰 생성 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합 테스트
|
||||
*/
|
||||
function integrationTest(): void {
|
||||
console.log("\n=== 통합 테스트 ===");
|
||||
|
||||
try {
|
||||
// 1. 비밀번호 암호화
|
||||
const password = "user1234";
|
||||
const encryptedPassword = PasswordUtils.encrypt(password);
|
||||
console.log("1. 비밀번호 암호화 완료");
|
||||
|
||||
// 2. 사용자 정보로 JWT 토큰 생성
|
||||
const userInfo: PersonBean = {
|
||||
...testUserInfo,
|
||||
userId: "integration_test_user",
|
||||
};
|
||||
const token = JwtUtils.generateToken(userInfo);
|
||||
console.log("2. JWT 토큰 생성 완료");
|
||||
|
||||
// 3. 토큰에서 사용자 정보 추출
|
||||
const extractedUserInfo = JwtUtils.verifyToken(token);
|
||||
console.log("3. 토큰 검증 완료");
|
||||
|
||||
// 4. 비밀번호 검증
|
||||
const passwordMatch = PasswordUtils.matches(password, encryptedPassword);
|
||||
console.log("4. 비밀번호 검증 완료");
|
||||
|
||||
// 5. 결과 확인
|
||||
const allTestsPassed =
|
||||
passwordMatch &&
|
||||
extractedUserInfo.userId === userInfo.userId &&
|
||||
extractedUserInfo.userName === userInfo.userName;
|
||||
|
||||
console.log("통합 테스트 결과:", allTestsPassed ? "성공" : "실패");
|
||||
} catch (error) {
|
||||
console.error("통합 테스트 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 테스트 실행
|
||||
*/
|
||||
function runAllTests(): void {
|
||||
console.log("🚀 인증 시스템 테스트 시작");
|
||||
console.log("=".repeat(50));
|
||||
|
||||
testPasswordUtils();
|
||||
testJwtUtils();
|
||||
testInvalidToken();
|
||||
integrationTest();
|
||||
|
||||
console.log("\n" + "=".repeat(50));
|
||||
console.log("✅ 인증 시스템 테스트 완료");
|
||||
}
|
||||
|
||||
// 테스트 실행
|
||||
if (require.main === module) {
|
||||
runAllTests();
|
||||
}
|
||||
|
||||
export {
|
||||
testPasswordUtils,
|
||||
testJwtUtils,
|
||||
testInvalidToken,
|
||||
integrationTest,
|
||||
runAllTests,
|
||||
};
|
||||
94
backend-node/src/types/auth.ts
Normal file
94
backend-node/src/types/auth.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// 기존 Java Spring Boot의 인증 관련 DTO 클래스들을 TypeScript로 포팅
|
||||
import { Request } from "express";
|
||||
|
||||
// 기존 ApiLoginController.LoginRequest 클래스 포팅
|
||||
export interface LoginRequest {
|
||||
userId: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// 기존 ApiLoginController.UserInfo 클래스 포팅
|
||||
export interface UserInfo {
|
||||
userId: string;
|
||||
userName: string;
|
||||
deptName: string;
|
||||
companyCode: string;
|
||||
userType?: string;
|
||||
userTypeName?: string;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
// 기존 ApiLoginController.ApiResponse 클래스 포팅
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
error?: {
|
||||
code: string;
|
||||
details?: any;
|
||||
};
|
||||
}
|
||||
|
||||
// 기존 PersonBean 클래스 포팅 (세션에 저장되는 사용자 정보)
|
||||
export interface PersonBean {
|
||||
userId: string;
|
||||
userName: string;
|
||||
userNameEng?: string;
|
||||
userNameCn?: string;
|
||||
deptCode?: string;
|
||||
deptName?: string;
|
||||
positionCode?: string;
|
||||
positionName?: string;
|
||||
email?: string;
|
||||
tel?: string;
|
||||
cellPhone?: string;
|
||||
userType?: string;
|
||||
userTypeName?: string;
|
||||
partnerObjid?: string;
|
||||
authName?: string;
|
||||
companyCode?: string;
|
||||
}
|
||||
|
||||
// 로그인 결과 타입 (기존 LoginService.loginPwdCheck 반환값)
|
||||
export interface LoginResult {
|
||||
loginResult: boolean;
|
||||
errorReason?: string;
|
||||
}
|
||||
|
||||
// 로그인 로그 데이터 타입 (기존 insertLoginAccessLog 파라미터)
|
||||
export interface LoginLogData {
|
||||
systemName: string;
|
||||
userId: string;
|
||||
loginResult: boolean;
|
||||
errorMessage?: string;
|
||||
remoteAddr: string;
|
||||
recptnDt?: string;
|
||||
recptnRsltDtl?: string;
|
||||
recptnRslt?: string;
|
||||
recptnRsltCd?: string;
|
||||
}
|
||||
|
||||
// JWT 토큰 페이로드 타입
|
||||
export interface JwtPayload {
|
||||
userId: string;
|
||||
userName: string;
|
||||
deptName?: string;
|
||||
companyCode?: string;
|
||||
userType?: string;
|
||||
userTypeName?: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
aud?: string;
|
||||
iss?: string;
|
||||
}
|
||||
|
||||
// 인증 상태 정보 타입 (기존 checkAuthStatus 반환값)
|
||||
export interface AuthStatusInfo {
|
||||
isAuthenticated: boolean;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
// 인증된 요청 타입 (미들웨어에서 사용)
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: PersonBean;
|
||||
}
|
||||
103
backend-node/src/types/common.ts
Normal file
103
backend-node/src/types/common.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Request } from "express";
|
||||
|
||||
// API 응답 타입
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: {
|
||||
code: string;
|
||||
details?: any;
|
||||
};
|
||||
pagination?: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 페이징 파라미터 타입
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
}
|
||||
|
||||
// 검색 파라미터 타입
|
||||
export interface SearchParams extends PaginationParams {
|
||||
search?: string;
|
||||
filters?: Record<string, any>;
|
||||
}
|
||||
|
||||
// JWT 페이로드 타입
|
||||
export interface JWTPayload {
|
||||
userId: string;
|
||||
userName?: string;
|
||||
email?: string;
|
||||
deptCode?: string;
|
||||
permissions?: string[];
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
// 인증된 요청 타입
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: JWTPayload;
|
||||
}
|
||||
|
||||
// 파일 업로드 타입
|
||||
export interface FileUpload {
|
||||
fieldname: string;
|
||||
originalname: string;
|
||||
encoding: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
destination: string;
|
||||
filename: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
// 성공 응답 헬퍼 함수
|
||||
export const successResponse = <T>(
|
||||
data: T,
|
||||
message?: string
|
||||
): ApiResponse<T> => ({
|
||||
success: true,
|
||||
data,
|
||||
message,
|
||||
});
|
||||
|
||||
// 에러 응답 헬퍼 함수
|
||||
export const errorResponse = (
|
||||
message: string,
|
||||
code?: string,
|
||||
details?: any
|
||||
): ApiResponse<never> => ({
|
||||
success: false,
|
||||
error: {
|
||||
code: code || "UNKNOWN_ERROR",
|
||||
details,
|
||||
},
|
||||
message,
|
||||
});
|
||||
|
||||
// 페이징 응답 헬퍼 함수
|
||||
export const paginatedResponse = <T>(
|
||||
data: T[],
|
||||
page: number,
|
||||
limit: number,
|
||||
total: number,
|
||||
message?: string
|
||||
): ApiResponse<T[]> => ({
|
||||
success: true,
|
||||
data,
|
||||
message,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
});
|
||||
174
backend-node/src/utils/jwtUtils.ts
Normal file
174
backend-node/src/utils/jwtUtils.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
// JWT 토큰 관리 유틸리티
|
||||
// 기존 PersonBean 정보를 JWT 페이로드로 변환
|
||||
|
||||
import jwt from "jsonwebtoken";
|
||||
import { PersonBean, JwtPayload } from "../types/auth";
|
||||
import config from "../config/environment";
|
||||
|
||||
export class JwtUtils {
|
||||
/**
|
||||
* 사용자 정보로 JWT 토큰 생성
|
||||
* 기존 PersonBean 정보를 JWT 페이로드로 변환
|
||||
*/
|
||||
static generateToken(userInfo: PersonBean): string {
|
||||
try {
|
||||
const payload: JwtPayload = {
|
||||
userId: userInfo.userId,
|
||||
userName: userInfo.userName,
|
||||
deptName: userInfo.deptName,
|
||||
companyCode: userInfo.companyCode,
|
||||
userType: userInfo.userType,
|
||||
userTypeName: userInfo.userTypeName,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, config.jwt.secret, {
|
||||
expiresIn: config.jwt.expiresIn,
|
||||
issuer: "PMS-System",
|
||||
audience: "PMS-Users",
|
||||
} as any);
|
||||
} catch (error) {
|
||||
console.error("JWT token generation error:", error);
|
||||
throw new Error("토큰 생성 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰 검증 및 사용자 정보 추출
|
||||
*/
|
||||
static verifyToken(token: string): PersonBean {
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as JwtPayload;
|
||||
|
||||
// PersonBean 형태로 변환
|
||||
const personBean: PersonBean = {
|
||||
userId: decoded.userId,
|
||||
userName: decoded.userName,
|
||||
deptName: decoded.deptName,
|
||||
companyCode: decoded.companyCode,
|
||||
userType: decoded.userType,
|
||||
userTypeName: decoded.userTypeName,
|
||||
};
|
||||
|
||||
return personBean;
|
||||
} catch (error) {
|
||||
if (error instanceof jwt.TokenExpiredError) {
|
||||
throw new Error("토큰이 만료되었습니다.");
|
||||
} else if (error instanceof jwt.JsonWebTokenError) {
|
||||
throw new Error("유효하지 않은 토큰입니다.");
|
||||
} else {
|
||||
console.error("JWT token verification error:", error);
|
||||
throw new Error("토큰 검증 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰에서 페이로드만 추출 (검증 없이)
|
||||
*/
|
||||
static decodeToken(token: string): JwtPayload | null {
|
||||
try {
|
||||
return jwt.decode(token) as JwtPayload;
|
||||
} catch (error) {
|
||||
console.error("JWT token decode error:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 만료 시간 확인
|
||||
*/
|
||||
static isTokenExpired(token: string): boolean {
|
||||
try {
|
||||
const decoded = jwt.decode(token) as JwtPayload;
|
||||
if (!decoded || !decoded.exp) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
return decoded.exp < currentTime;
|
||||
} catch (error) {
|
||||
console.error("Token expiration check error:", error);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 갱신 (만료 시간만 연장)
|
||||
*/
|
||||
static refreshToken(token: string): string {
|
||||
try {
|
||||
const decoded = jwt.decode(token) as JwtPayload;
|
||||
if (!decoded) {
|
||||
throw new Error("토큰을 디코드할 수 없습니다.");
|
||||
}
|
||||
|
||||
// 페이로드에서 만료 시간 관련 필드 제거
|
||||
const { iat, exp, aud, iss, ...payload } = decoded;
|
||||
|
||||
return jwt.sign(payload, config.jwt.secret, {
|
||||
expiresIn: config.jwt.expiresIn,
|
||||
issuer: "PMS-System",
|
||||
} as any);
|
||||
} catch (error) {
|
||||
console.error("JWT token refresh error:", error);
|
||||
throw new Error("토큰 갱신 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰에서 사용자 ID 추출
|
||||
*/
|
||||
static getUserIdFromToken(token: string): string | null {
|
||||
try {
|
||||
const decoded = jwt.decode(token) as JwtPayload;
|
||||
return decoded?.userId || null;
|
||||
} catch (error) {
|
||||
console.error("Get user ID from token error:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 유효성 검사 (만료 여부 포함)
|
||||
*/
|
||||
static validateToken(token: string): { isValid: boolean; error?: string } {
|
||||
try {
|
||||
jwt.verify(token, config.jwt.secret);
|
||||
return { isValid: true };
|
||||
} catch (error) {
|
||||
if (error instanceof jwt.TokenExpiredError) {
|
||||
return { isValid: false, error: "토큰이 만료되었습니다." };
|
||||
} else if (error instanceof jwt.JsonWebTokenError) {
|
||||
return { isValid: false, error: "유효하지 않은 토큰입니다." };
|
||||
} else {
|
||||
return { isValid: false, error: "토큰 검증 중 오류가 발생했습니다." };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트용 메서드 (개발 환경에서만 사용)
|
||||
*/
|
||||
static testJwtUtils(userInfo: PersonBean): void {
|
||||
console.log("=== JWT 토큰 테스트 ===");
|
||||
console.log("사용자 정보:", userInfo);
|
||||
|
||||
const token = this.generateToken(userInfo);
|
||||
console.log("생성된 토큰:", token);
|
||||
|
||||
const decoded = this.decodeToken(token);
|
||||
console.log("디코드된 페이로드:", decoded);
|
||||
|
||||
const verified = this.verifyToken(token);
|
||||
console.log("검증된 사용자 정보:", verified);
|
||||
|
||||
const isExpired = this.isTokenExpired(token);
|
||||
console.log("토큰 만료 여부:", isExpired);
|
||||
|
||||
const userId = this.getUserIdFromToken(token);
|
||||
console.log("토큰에서 추출한 사용자 ID:", userId);
|
||||
|
||||
const validation = this.validateToken(token);
|
||||
console.log("토큰 유효성 검사:", validation);
|
||||
}
|
||||
}
|
||||
66
backend-node/src/utils/logger.ts
Normal file
66
backend-node/src/utils/logger.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import winston from "winston";
|
||||
import config from "../config/environment";
|
||||
|
||||
// 로그 포맷 정의
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp({
|
||||
format: "YYYY-MM-DD HH:mm:ss",
|
||||
}),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
// 콘솔 포맷 (개발 환경용)
|
||||
const consoleFormat = winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.timestamp({
|
||||
format: "YYYY-MM-DD HH:mm:ss",
|
||||
}),
|
||||
winston.format.printf(({ timestamp, level, message, stack }) => {
|
||||
if (stack) {
|
||||
return `${timestamp} [${level}]: ${message}\n${stack}`;
|
||||
}
|
||||
return `${timestamp} [${level}]: ${message}`;
|
||||
})
|
||||
);
|
||||
|
||||
// 로거 생성
|
||||
export const logger = winston.createLogger({
|
||||
level: config.logging.level,
|
||||
format: logFormat,
|
||||
transports: [
|
||||
// 파일 로그 (에러)
|
||||
new winston.transports.File({
|
||||
filename: "logs/error.log",
|
||||
level: "error",
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5,
|
||||
}),
|
||||
// 파일 로그 (전체)
|
||||
new winston.transports.File({
|
||||
filename: "logs/combined.log",
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// 개발 환경에서는 콘솔 출력 추가
|
||||
if (config.nodeEnv !== "production") {
|
||||
logger.add(
|
||||
new winston.transports.Console({
|
||||
format: consoleFormat,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 로그 디렉토리 생성
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const logsDir = path.join(process.cwd(), "logs");
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
export default logger;
|
||||
197
backend-node/src/utils/passwordUtils.ts
Normal file
197
backend-node/src/utils/passwordUtils.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
// 기존 Java EncryptUtil 클래스를 Node.js로 포팅
|
||||
// AES/ECB/NoPadding 암호화 방식 사용
|
||||
|
||||
import crypto from "crypto";
|
||||
|
||||
// 기존 Java Constants에서 가져온 값들
|
||||
const KEY_NAME = "ILJIAESSECRETKEY";
|
||||
const ALGORITHM = "AES";
|
||||
const MASTER_PWD = "qlalfqjsgh11";
|
||||
|
||||
export class PasswordUtils {
|
||||
/**
|
||||
* 기존 Java EncryptUtil.encrypt() 메서드 포팅
|
||||
* AES/ECB/NoPadding 방식으로 암호화
|
||||
*/
|
||||
static encrypt(source: string): string {
|
||||
try {
|
||||
// 16바이트 키 생성 (AES-128)
|
||||
const key = Buffer.from(KEY_NAME, "utf8").slice(0, 16);
|
||||
|
||||
// 패딩 추가 (16바이트 블록 크기에 맞춤)
|
||||
const paddedData = this.addPadding(Buffer.from(source, "utf8"));
|
||||
|
||||
// AES 암호화
|
||||
const cipher = crypto.createCipher("aes-128-ecb", key);
|
||||
cipher.setAutoPadding(false); // NoPadding 모드
|
||||
|
||||
let encrypted = cipher.update(paddedData);
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
|
||||
// 16진수 문자열로 변환
|
||||
return this.fromHex(encrypted);
|
||||
} catch (error) {
|
||||
console.error("Password encryption error:", error);
|
||||
throw new Error("암호화 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 Java EncryptUtil.decrypt() 메서드 포팅
|
||||
*/
|
||||
static decrypt(source: string): string {
|
||||
try {
|
||||
// 16바이트 키 생성
|
||||
const key = Buffer.from(KEY_NAME, "utf8").slice(0, 16);
|
||||
|
||||
// 16진수 문자열을 바이트 배열로 변환
|
||||
const encryptedData = this.toBytes(source);
|
||||
|
||||
// AES 복호화
|
||||
const decipher = crypto.createDecipher("aes-128-ecb", key);
|
||||
decipher.setAutoPadding(false); // NoPadding 모드
|
||||
|
||||
let decrypted = decipher.update(encryptedData);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
|
||||
// 패딩 제거
|
||||
const unpaddedData = this.removePadding(decrypted);
|
||||
|
||||
return unpaddedData.toString("utf8");
|
||||
} catch (error) {
|
||||
console.error("Password decryption error:", error);
|
||||
throw new Error("복호화 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 Java EncryptUtil.encryptSha256() 메서드 포팅
|
||||
*/
|
||||
static encryptSha256(s: string): string {
|
||||
try {
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update(s, "utf8");
|
||||
return hash.digest("hex");
|
||||
} catch (error) {
|
||||
console.error("SHA256 encryption error:", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 검증 (기존 Java 로직과 동일)
|
||||
*/
|
||||
static matches(plainPassword: string, encryptedPassword: string): boolean {
|
||||
try {
|
||||
// 마스터 패스워드 체크
|
||||
if (MASTER_PWD === plainPassword) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 일반 패스워드 암호화 후 비교
|
||||
const encryptedPlainPassword = this.encrypt(plainPassword);
|
||||
return encryptedPlainPassword === encryptedPassword;
|
||||
} catch (error) {
|
||||
console.error("Password matching error:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 Java addPadding() 메서드 포팅
|
||||
* 16바이트 블록 크기에 맞춰 패딩 추가
|
||||
*/
|
||||
private static addPadding(pBytes: Buffer): Buffer {
|
||||
const pCount = pBytes.length;
|
||||
const tCount = pCount + (16 - (pCount % 16));
|
||||
const tBytes = Buffer.alloc(tCount);
|
||||
|
||||
pBytes.copy(tBytes, 0);
|
||||
|
||||
// 나머지 바이트를 0x00으로 채움
|
||||
for (let rIndex = pCount; rIndex < tCount; rIndex++) {
|
||||
tBytes[rIndex] = 0x00;
|
||||
}
|
||||
|
||||
return tBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 Java removePadding() 메서드 포팅
|
||||
* 패딩 제거
|
||||
*/
|
||||
private static removePadding(pBytes: Buffer): Buffer {
|
||||
const pCount = pBytes.length;
|
||||
let index = 0;
|
||||
let loop = true;
|
||||
|
||||
while (loop) {
|
||||
if (index === pCount || pBytes[index] === 0x00) {
|
||||
loop = false;
|
||||
index--;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
const tBytes = Buffer.alloc(index);
|
||||
pBytes.copy(tBytes, 0, 0, index);
|
||||
|
||||
return tBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 Java toBytes() 메서드 포팅
|
||||
* 16진수 문자열을 바이트 배열로 변환
|
||||
*/
|
||||
private static toBytes(pSource: string): Buffer {
|
||||
const buff = pSource;
|
||||
const bCount = Math.floor(buff.length / 2);
|
||||
const bArr = Buffer.alloc(bCount);
|
||||
|
||||
for (let bIndex = 0; bIndex < bCount; bIndex++) {
|
||||
const hexByte = buff.substring(2 * bIndex, 2 * bIndex + 2);
|
||||
bArr[bIndex] = parseInt(hexByte, 16);
|
||||
}
|
||||
|
||||
return bArr;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 Java fromHex() 메서드 포팅
|
||||
* 바이트 배열을 16진수 문자열로 변환
|
||||
*/
|
||||
private static fromHex(pBytes: Buffer): string {
|
||||
const pCount = pBytes.length;
|
||||
let buff = "";
|
||||
|
||||
for (let pIndex = 0; pIndex < pCount; pIndex++) {
|
||||
const byte = pBytes[pIndex] & 0xff;
|
||||
if (byte < 0x10) {
|
||||
buff += "0";
|
||||
}
|
||||
buff += byte.toString(16);
|
||||
}
|
||||
|
||||
return buff;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트용 메서드 (개발 환경에서만 사용)
|
||||
*/
|
||||
static testEncryption(plainText: string): void {
|
||||
console.log("=== 암호화 테스트 ===");
|
||||
console.log("원본 텍스트:", plainText);
|
||||
|
||||
const encrypted = this.encrypt(plainText);
|
||||
console.log("암호화 결과:", encrypted);
|
||||
|
||||
const decrypted = this.decrypt(encrypted);
|
||||
console.log("복호화 결과:", decrypted);
|
||||
|
||||
const isMatch = this.matches(plainText, encrypted);
|
||||
console.log("비밀번호 일치:", isMatch);
|
||||
|
||||
const sha256Hash = this.encryptSha256(plainText);
|
||||
console.log("SHA256 해시:", sha256Hash);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user