최초커밋

This commit is contained in:
kjs
2025-08-21 09:41:46 +09:00
commit a0e5b57a24
2454 changed files with 1476904 additions and 0 deletions

86
backend-node/src/app.ts Normal file
View 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;

View 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);
}
}

View 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
: "알 수 없는 오류가 발생했습니다.",
},
});
}
}
}

View 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: "인증 상태 확인 중 오류가 발생했습니다.",
},
});
}
};

View 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,
},
});
};

View 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;

View 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;

View 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;
}
}
}

View 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}`
);
}
}
}

View 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,
};

View 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;
}

View 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),
},
});

View 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);
}
}

View 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;

View 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);
}
}