From 70e040db39d94ad53de2edf4a4c13bee0822b113 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 25 Mar 2026 18:47:50 +0900 Subject: [PATCH] Enhance user management and token invalidation features - Added comprehensive validation for user data during registration and updates, including email format, company code existence, user type validation, and password length checks. - Implemented JWT token invalidation for users when their status changes or when roles are updated, ensuring security and compliance with the latest policies. - Introduced a new TokenInvalidationService to manage token versioning and invalidation processes efficiently. - Updated the admin controller to provide detailed error messages and success responses for user status changes and validations. - Enhanced the authentication middleware to check token versions against the database, ensuring that invalidated tokens cannot be used. This commit improves the overall security and user management experience within the application. --- .../src/controllers/adminController.ts | 364 ++++++++++++++++++ .../src/controllers/roleController.ts | 27 ++ backend-node/src/middleware/authMiddleware.ts | 38 +- backend-node/src/routes/adminRoutes.ts | 2 + backend-node/src/services/auditLogService.ts | 3 +- backend-node/src/services/authService.ts | 5 +- backend-node/src/services/roleService.ts | 67 ++-- .../src/services/tokenInvalidationService.ts | 75 ++++ backend-node/src/types/auth.ts | 2 + backend-node/src/utils/jwtUtils.ts | 1 + .../production/plan-management/page.tsx | 18 +- frontend/lib/api/client.ts | 7 + 12 files changed, 573 insertions(+), 36 deletions(-) create mode 100644 backend-node/src/services/tokenInvalidationService.ts diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index dc8cf064..d7aa247d 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -2504,7 +2504,9 @@ export const changeUserStatus = async ( // 필수 파라미터 검증 if (!userId || !status) { res.status(400).json({ + success: false, result: false, + message: "사용자 ID와 상태는 필수입니다.", msg: "사용자 ID와 상태는 필수입니다.", }); return; @@ -2513,7 +2515,9 @@ export const changeUserStatus = async ( // 상태 값 검증 if (!["active", "inactive"].includes(status)) { res.status(400).json({ + success: false, result: false, + message: "유효하지 않은 상태값입니다. (active, inactive만 허용)", msg: "유효하지 않은 상태값입니다. (active, inactive만 허용)", }); return; @@ -2528,7 +2532,9 @@ export const changeUserStatus = async ( if (!currentUser) { res.status(404).json({ + success: false, result: false, + message: "사용자를 찾을 수 없습니다.", msg: "사용자를 찾을 수 없습니다.", }); return; @@ -2549,6 +2555,12 @@ export const changeUserStatus = async ( if (updateResult.length > 0) { // 사용자 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략 + // inactive로 변경 시 기존 JWT 토큰 무효화 + if (status === "inactive") { + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateUserTokens(userId); + } + logger.info("사용자 상태 변경 성공", { userId, oldStatus: currentUser.status, @@ -2571,12 +2583,16 @@ export const changeUserStatus = async ( }); res.json({ + success: true, result: true, + message: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`, msg: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`, }); } else { res.status(400).json({ + success: false, result: false, + message: "사용자 상태 변경에 실패했습니다.", msg: "사용자 상태 변경에 실패했습니다.", }); } @@ -2587,7 +2603,9 @@ export const changeUserStatus = async ( status: req.body.status, }); res.status(500).json({ + success: false, result: false, + message: "시스템 오류가 발생했습니다.", msg: "시스템 오류가 발생했습니다.", }); } @@ -2627,12 +2645,214 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => { } } + // 추가 유효성 검증 + + // 1. email 형식 검증 (값이 있는 경우만) + if (userData.email && userData.email.trim() !== "") { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(userData.email.trim())) { + res.status(400).json({ + success: false, + message: "이메일 형식이 올바르지 않습니다.", + error: { + code: "INVALID_EMAIL_FORMAT", + details: `Invalid email format: ${userData.email}`, + }, + }); + return; + } + } + + // 2. companyCode 존재 확인 (값이 있는 경우만) + if (userData.companyCode && userData.companyCode.trim() !== "") { + const companyExists = await queryOne<{ company_code: string }>( + `SELECT company_code FROM company_mng WHERE company_code = $1`, + [userData.companyCode.trim()] + ); + if (!companyExists) { + res.status(400).json({ + success: false, + message: `존재하지 않는 회사 코드입니다: ${userData.companyCode}`, + error: { + code: "INVALID_COMPANY_CODE", + details: `Company code not found: ${userData.companyCode}`, + }, + }); + return; + } + } + + // 3. userType 유효값 검증 (값이 있는 경우만) + if (userData.userType && userData.userType.trim() !== "") { + const validUserTypes = ["SUPER_ADMIN", "COMPANY_ADMIN", "USER", "GUEST", "PARTNER"]; + if (!validUserTypes.includes(userData.userType.trim())) { + res.status(400).json({ + success: false, + message: `유효하지 않은 사용자 유형입니다: ${userData.userType}. 허용값: ${validUserTypes.join(", ")}`, + error: { + code: "INVALID_USER_TYPE", + details: `Invalid userType: ${userData.userType}. Allowed: ${validUserTypes.join(", ")}`, + }, + }); + return; + } + } + + // 4. 비밀번호 최소 길이 검증 (신규 등록 시) + if (!isUpdate && userData.userPassword && userData.userPassword.length < 4) { + res.status(400).json({ + success: false, + message: "비밀번호는 최소 4자 이상이어야 합니다.", + error: { + code: "PASSWORD_TOO_SHORT", + details: "Password must be at least 4 characters long", + }, + }); + return; + } + // 비밀번호 암호화 (비밀번호가 제공된 경우에만) let encryptedPassword = null; if (userData.userPassword) { encryptedPassword = await EncryptUtil.encrypt(userData.userPassword); } + // PUT(수정) 요청 시 company_code / dept_code 변경 감지 + if (isUpdate) { + const existingUser = await queryOne<{ company_code: string; dept_code: string }>( + `SELECT company_code, dept_code FROM user_info WHERE user_id = $1`, + [userData.userId] + ); + + // company_code 변경 감지 → 이전 회사 권한 그룹 제거 + if ( + userData.companyCode && + existingUser && + existingUser.company_code && + existingUser.company_code !== userData.companyCode + ) { + const oldCompanyCode = existingUser.company_code; + logger.info("사용자 회사 코드 변경 감지 - 이전 회사 권한 그룹 제거", { + userId: userData.userId, + oldCompanyCode, + newCompanyCode: userData.companyCode, + }); + + // 이전 회사의 권한 그룹에서 해당 사용자 제거 + await query( + `DELETE FROM authority_sub_user + WHERE user_id = $1 + AND master_objid IN ( + SELECT objid FROM authority_master WHERE company_code = $2 + )`, + [userData.userId, oldCompanyCode] + ); + } + + // dept_code 변경 감지 → 결재 템플릿/진행중 결재라인 경고 로그 + const newDeptCode = userData.deptCode || null; + const oldDeptCode = existingUser?.dept_code || null; + if (existingUser && oldDeptCode && newDeptCode && oldDeptCode !== newDeptCode) { + logger.warn("사용자 부서 변경 감지 - 결재라인 영향 확인 시작", { + userId: userData.userId, + userName: userData.userName, + oldDeptCode, + newDeptCode, + }); + + try { + // 1) 결재선 템플릿 스텝에서 해당 사용자가 결재자로 등록된 건 조회 + const templateSteps = await query<{ + template_id: number; + step_order: number; + approver_label: string | null; + approver_dept_code: string | null; + }>( + `SELECT s.template_id, s.step_order, s.approver_label, s.approver_dept_code + FROM approval_line_template_steps s + WHERE s.approver_user_id = $1`, + [userData.userId] + ); + + if (templateSteps && templateSteps.length > 0) { + logger.warn( + `[결재라인 경고] 부서 변경된 사용자(${userData.userId})가 결재선 템플릿 ${templateSteps.length}건에 결재자로 등록되어 있습니다. 수동 확인이 필요합니다.`, + { + userId: userData.userId, + oldDeptCode, + newDeptCode, + affectedTemplates: templateSteps.map((s) => ({ + templateId: s.template_id, + stepOrder: s.step_order, + label: s.approver_label, + currentDeptInStep: s.approver_dept_code, + })), + } + ); + } + + // 2) 진행중인 결재 요청에서 해당 사용자가 대기중 결재자인 건 조회 + const pendingLines = await query<{ + request_id: number; + step_order: number; + approver_dept: string | null; + status: string; + }>( + `SELECT l.request_id, l.step_order, l.approver_dept, l.status + FROM approval_lines l + JOIN approval_requests r ON r.request_id = l.request_id + WHERE l.approver_id = $1 + AND l.status = 'pending' + AND r.status IN ('in_progress', 'pending')`, + [userData.userId] + ); + + if (pendingLines && pendingLines.length > 0) { + logger.warn( + `[결재라인 경고] 부서 변경된 사용자(${userData.userId})에게 대기중인 결재 ${pendingLines.length}건이 있습니다. 수동 확인이 필요합니다.`, + { + userId: userData.userId, + oldDeptCode, + newDeptCode, + pendingApprovals: pendingLines.map((l) => ({ + requestId: l.request_id, + stepOrder: l.step_order, + currentDeptInLine: l.approver_dept, + })), + } + ); + } + + // 감사 로그 기록 + auditLogService.log({ + companyCode: userData.companyCode || req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "DEPT_CHANGE_WARNING", + resourceType: "USER", + resourceId: userData.userId, + resourceName: userData.userName, + summary: `사용자 "${userData.userName}"의 부서 변경 (${oldDeptCode} → ${newDeptCode}). 결재 템플릿 ${templateSteps?.length || 0}건, 대기중 결재 ${pendingLines?.length || 0}건 영향 가능`, + changes: { + before: { deptCode: oldDeptCode }, + after: { + deptCode: newDeptCode, + affectedTemplateCount: templateSteps?.length || 0, + pendingApprovalCount: pendingLines?.length || 0, + }, + }, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + } catch (approvalCheckError) { + // 결재 테이블이 없는 환경에서도 사용자 저장은 계속 진행 + logger.warn("결재라인 영향 확인 중 오류 (사용자 저장은 계속 진행)", { + error: approvalCheckError instanceof Error ? approvalCheckError.message : approvalCheckError, + }); + } + } + } + // Raw Query를 사용한 사용자 저장 (upsert with ON CONFLICT) const updatePasswordClause = encryptedPassword ? "user_password = $4," : ""; @@ -2688,6 +2908,12 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => { savedUser.regdate && new Date(savedUser.regdate).getTime() < Date.now() - 1000; + // 기존 사용자의 비밀번호 변경 시 JWT 토큰 무효화 + if (encryptedPassword && isExistingUser) { + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateUserTokens(userData.userId); + } + logger.info( isExistingUser ? "사용자 정보 수정 완료" : "새 사용자 등록 완료", { @@ -3534,6 +3760,10 @@ export const resetUserPassword = async ( if (updateResult.length > 0) { // 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략 + // 비밀번호 변경 후 기존 JWT 토큰 무효화 + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateUserTokens(userId); + logger.info("비밀번호 초기화 성공", { userId, updatedBy: req.user?.userId, @@ -4153,6 +4383,140 @@ export const saveUserWithDept = async ( * GET /api/admin/users/:userId/with-dept * 사원 + 부서 정보 조회 API (수정 모달용) */ +/** + * DELETE /api/admin/users/:userId + * 사용자 삭제 API (soft delete) + * status = 'deleted', end_date = now() 설정 + * authority_sub_user 멤버십 제거, JWT 토큰 무효화 + */ +export const deleteUser = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { userId } = req.params; + + // 1. userId 파라미터 검증 + if (!userId) { + res.status(400).json({ + success: false, + result: false, + message: "사용자 ID는 필수입니다.", + }); + return; + } + + // 2. 자기 자신 삭제 방지 + if (req.user?.userId === userId) { + res.status(400).json({ + success: false, + result: false, + message: "자기 자신은 삭제할 수 없습니다.", + }); + return; + } + + // 3. 사용자 존재 여부 확인 + const currentUser = await queryOne( + `SELECT user_id, user_name, status, company_code FROM user_info WHERE user_id = $1`, + [userId] + ); + + if (!currentUser) { + res.status(404).json({ + success: false, + result: false, + message: "사용자를 찾을 수 없습니다.", + }); + return; + } + + // 이미 삭제된 사용자 체크 + if (currentUser.status === "deleted") { + res.status(400).json({ + success: false, + result: false, + message: "이미 삭제된 사용자입니다.", + }); + return; + } + + // 4. soft delete: status = 'deleted', end_date = now() + const updateResult = await query( + `UPDATE user_info + SET status = 'deleted', end_date = NOW() + WHERE user_id = $1 + RETURNING *`, + [userId] + ); + + if (updateResult.length === 0) { + res.status(500).json({ + success: false, + result: false, + message: "사용자 삭제에 실패했습니다.", + }); + return; + } + + // 5. authority_sub_user에서 해당 사용자 멤버십 제거 + await query( + `DELETE FROM authority_sub_user WHERE user_id = $1`, + [userId] + ); + + // 6. JWT 토큰 무효화 + try { + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateUserTokens(userId); + } catch (tokenError) { + logger.warn("토큰 무효화 중 오류 (삭제는 정상 처리됨)", { userId, error: tokenError }); + } + + logger.info("사용자 삭제(soft delete) 성공", { + userId, + userName: currentUser.user_name, + deletedBy: req.user?.userId, + }); + + // 7. 감사 로그 기록 + auditLogService.log({ + companyCode: currentUser.company_code || req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "DELETE", + resourceType: "USER", + resourceId: userId, + resourceName: currentUser.user_name, + summary: `사용자 "${currentUser.user_name}" (${userId}) 삭제 처리`, + changes: { + before: { status: currentUser.status }, + after: { status: "deleted" }, + fields: ["status", "end_date"], + }, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + + // 8. 응답 + res.json({ + success: true, + result: true, + message: `사용자 "${currentUser.user_name}" (${userId})이(가) 삭제되었습니다.`, + }); + } catch (error: any) { + logger.error("사용자 삭제 중 오류 발생", { + error: error.message, + userId: req.params.userId, + }); + res.status(500).json({ + success: false, + result: false, + message: "시스템 오류가 발생했습니다.", + }); + } +}; + export const getUserWithDept = async ( req: AuthenticatedRequest, res: Response diff --git a/backend-node/src/controllers/roleController.ts b/backend-node/src/controllers/roleController.ts index 06f72f31..29bc4b0e 100644 --- a/backend-node/src/controllers/roleController.ts +++ b/backend-node/src/controllers/roleController.ts @@ -472,6 +472,10 @@ export const addRoleMembers = async ( req.user?.userId || "SYSTEM" ); + // 권한 변경된 사용자들의 JWT 토큰 무효화 + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateMultipleUserTokens(userIds); + const response: ApiResponse = { success: true, message: "권한 그룹 멤버 추가 성공", @@ -568,6 +572,13 @@ export const updateRoleMembers = async ( ); } + // 권한 변경된 사용자들의 JWT 토큰 무효화 + const allAffectedUsers = [...new Set([...toAdd, ...toRemove])]; + if (allAffectedUsers.length > 0) { + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateMultipleUserTokens(allAffectedUsers); + } + logger.info("권한 그룹 멤버 일괄 업데이트 성공", { masterObjid, added: toAdd.length, @@ -646,6 +657,10 @@ export const removeRoleMembers = async ( req.user?.userId || "SYSTEM" ); + // 권한 변경된 사용자들의 JWT 토큰 무효화 + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateMultipleUserTokens(userIds); + const response: ApiResponse = { success: true, message: "권한 그룹 멤버 제거 성공", @@ -777,6 +792,18 @@ export const setMenuPermissions = async ( req.user?.userId || "SYSTEM" ); + // 해당 권한 그룹의 모든 멤버 JWT 토큰 무효화 + try { + const members = await RoleService.getRoleMembers(authObjid); + const memberIds = members.map((m: any) => m.userId); + if (memberIds.length > 0) { + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateMultipleUserTokens(memberIds); + } + } catch (invalidateError) { + logger.warn("메뉴 권한 변경 후 토큰 무효화 실패 (권한 설정은 성공)", { invalidateError }); + } + const response: ApiResponse = { success: true, message: "메뉴 권한 설정 성공", diff --git a/backend-node/src/middleware/authMiddleware.ts b/backend-node/src/middleware/authMiddleware.ts index 938988b5..8dfe28b3 100644 --- a/backend-node/src/middleware/authMiddleware.ts +++ b/backend-node/src/middleware/authMiddleware.ts @@ -5,6 +5,7 @@ import { Request, Response, NextFunction } from "express"; import { JwtUtils } from "../utils/jwtUtils"; import { AuthenticatedRequest, PersonBean } from "../types/auth"; import { logger } from "../utils/logger"; +import { TokenInvalidationService } from "../services/tokenInvalidationService"; // AuthenticatedRequest 타입을 다른 모듈에서 사용할 수 있도록 re-export export { AuthenticatedRequest } from "../types/auth"; @@ -22,11 +23,11 @@ declare global { * JWT 토큰 검증 미들웨어 * 기존 세션 방식과 동일한 효과를 제공 */ -export const authenticateToken = ( +export const authenticateToken = async ( req: AuthenticatedRequest, res: Response, next: NextFunction -): void => { +): Promise => { try { // Authorization 헤더에서 토큰 추출 const authHeader = req.get("Authorization"); @@ -46,6 +47,25 @@ export const authenticateToken = ( // JWT 토큰 검증 및 사용자 정보 추출 const userInfo: PersonBean = JwtUtils.verifyToken(token); + // token_version 검증 (JWT payload vs DB) + const decoded = JwtUtils.decodeToken(token); + const tokenVersion = decoded?.tokenVersion; + + // tokenVersion이 undefined면 구버전 토큰이므로 통과 (하위 호환) + if (tokenVersion !== undefined) { + const dbVersion = await TokenInvalidationService.getUserTokenVersion(userInfo.userId); + if (tokenVersion !== dbVersion) { + res.status(401).json({ + success: false, + error: { + code: "TOKEN_INVALIDATED", + details: "보안 정책에 의해 재로그인이 필요합니다.", + }, + }); + return; + } + } + // 요청 객체에 사용자 정보 설정 (기존 PersonBean과 동일) req.user = userInfo; @@ -173,11 +193,11 @@ export const requireUserOrAdmin = (targetUserId: string) => { * 토큰 갱신 미들웨어 * 토큰이 곧 만료될 경우 자동으로 갱신 */ -export const refreshTokenIfNeeded = ( +export const refreshTokenIfNeeded = async ( req: AuthenticatedRequest, res: Response, next: NextFunction -): void => { +): Promise => { try { const authHeader = req.get("Authorization"); const token = authHeader && authHeader.split(" ")[1]; @@ -191,6 +211,16 @@ export const refreshTokenIfNeeded = ( // 1시간(3600초) 이내에 만료되는 경우 갱신 if (timeUntilExpiry > 0 && timeUntilExpiry < 3600) { + // 갱신 전 token_version 검증 + if (decoded.tokenVersion !== undefined) { + const dbVersion = await TokenInvalidationService.getUserTokenVersion(decoded.userId); + if (decoded.tokenVersion !== dbVersion) { + // 무효화된 토큰은 갱신하지 않음 + next(); + return; + } + } + const newToken = JwtUtils.refreshToken(token); // 새로운 토큰을 응답 헤더에 포함 diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index a0779d50..d0ddbd6c 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -21,6 +21,7 @@ import { saveUser, // 사용자 등록/수정 saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!) getUserWithDept, // 사원 + 부서 조회 (NEW!) + deleteUser, // 사용자 삭제 (soft delete) getCompanyList, getCompanyListFromDB, // 실제 DB에서 회사 목록 조회 getCompanyByCode, // 회사 단건 조회 @@ -62,6 +63,7 @@ router.put("/users/:userId", saveUser); // 사용자 수정 (REST API) router.put("/profile", updateProfile); // 프로필 수정 router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크 router.post("/users/reset-password", resetUserPassword); // 사용자 비밀번호 초기화 +router.delete("/users/:userId", deleteUser); // 사용자 삭제 (soft delete) // 부서 관리 API router.get("/departments", getDepartmentList); // 부서 목록 조회 diff --git a/backend-node/src/services/auditLogService.ts b/backend-node/src/services/auditLogService.ts index 82c2566e..d62d1d71 100644 --- a/backend-node/src/services/auditLogService.ts +++ b/backend-node/src/services/auditLogService.ts @@ -24,7 +24,8 @@ export type AuditAction = | "STATUS_CHANGE" | "BATCH_CREATE" | "BATCH_UPDATE" - | "BATCH_DELETE"; + | "BATCH_DELETE" + | "DEPT_CHANGE_WARNING"; export type AuditResourceType = | "MENU" diff --git a/backend-node/src/services/authService.ts b/backend-node/src/services/authService.ts index 5bbf3089..c83c5874 100644 --- a/backend-node/src/services/authService.ts +++ b/backend-node/src/services/authService.ts @@ -134,12 +134,14 @@ export class AuthService { company_code: string | null; locale: string | null; photo: Buffer | null; + token_version: number | null; }>( `SELECT sabun, user_id, user_name, user_name_eng, user_name_cn, dept_code, dept_name, position_code, position_name, email, tel, cell_phone, user_type, user_type_name, - partner_objid, company_code, locale, photo + partner_objid, company_code, locale, photo, + COALESCE(token_version, 0) as token_version FROM user_info WHERE user_id = $1`, [userId] @@ -210,6 +212,7 @@ export class AuthService { ? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}` : undefined, locale: userInfo.locale || "KR", + tokenVersion: userInfo.token_version ?? 0, // 권한 레벨 정보 추가 (3단계 체계) isSuperAdmin: companyCode === "*" && userType === "SUPER_ADMIN", isCompanyAdmin: userType === "COMPANY_ADMIN" && companyCode !== "*", diff --git a/backend-node/src/services/roleService.ts b/backend-node/src/services/roleService.ts index abf19f40..2696dfce 100644 --- a/backend-node/src/services/roleService.ts +++ b/backend-node/src/services/roleService.ts @@ -1,4 +1,4 @@ -import { query } from "../database/db"; +import { query, transaction } from "../database/db"; import { logger } from "../utils/logger"; /** @@ -145,10 +145,19 @@ export class RoleService { writer: string; }): Promise { try { + // 동일 회사 내 같은 이름의 권한 그룹 중복 체크 + const dupCheck = await query<{ count: string }>( + `SELECT COUNT(*) AS count FROM authority_master WHERE company_code = $1 AND auth_name = $2`, + [data.companyCode, data.authName] + ); + if (dupCheck.length > 0 && parseInt(dupCheck[0].count, 10) > 0) { + throw new Error(`동일 회사 내에 이미 같은 이름의 권한 그룹이 존재합니다: ${data.authName}`); + } + const sql = ` INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate) VALUES (nextval('seq_authority_master'), $1, $2, $3, 'active', $4, NOW()) - RETURNING objid, auth_name AS "authName", auth_code AS "authCode", + RETURNING objid, auth_name AS "authName", auth_code AS "authCode", company_code AS "companyCode", status, writer, regdate `; @@ -460,35 +469,37 @@ export class RoleService { writer: string ): Promise { try { - // 기존 권한 삭제 - await query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [ - authObjid, - ]); - - // 새로운 권한 삽입 - if (permissions.length > 0) { - const values = permissions - .map( - (_, index) => - `(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())` - ) - .join(", "); - - const params = permissions.flatMap((p) => [ - p.menuObjid, - p.createYn, - p.readYn, - p.updateYn, - p.deleteYn, + await transaction(async (client) => { + // 기존 권한 삭제 + await client.query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [ + authObjid, ]); - const sql = ` - INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate) - VALUES ${values} - `; + // 새로운 권한 삽입 + if (permissions.length > 0) { + const values = permissions + .map( + (_, index) => + `(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())` + ) + .join(", "); - await query(sql, [authObjid, ...params, writer]); - } + const params = permissions.flatMap((p) => [ + p.menuObjid, + p.createYn, + p.readYn, + p.updateYn, + p.deleteYn, + ]); + + const sql = ` + INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate) + VALUES ${values} + `; + + await client.query(sql, [authObjid, ...params, writer]); + } + }); logger.info("메뉴 권한 설정 성공", { authObjid, diff --git a/backend-node/src/services/tokenInvalidationService.ts b/backend-node/src/services/tokenInvalidationService.ts new file mode 100644 index 00000000..6bcddc13 --- /dev/null +++ b/backend-node/src/services/tokenInvalidationService.ts @@ -0,0 +1,75 @@ +// JWT 토큰 무효화 서비스 +// user_info.token_version 기반으로 기존 JWT 토큰을 무효화 + +import { query } from "../database/db"; +import { cache } from "../utils/cache"; +import { logger } from "../utils/logger"; + +const TOKEN_VERSION_CACHE_TTL = 2 * 60 * 1000; // 2분 캐시 + +export class TokenInvalidationService { + /** + * 캐시 키 생성 + */ + static cacheKey(userId: string): string { + return `token_version:${userId}`; + } + + /** + * 단일 사용자의 토큰 무효화 (token_version +1) + */ + static async invalidateUserTokens(userId: string): Promise { + try { + await query( + `UPDATE user_info SET token_version = COALESCE(token_version, 0) + 1 WHERE user_id = $1`, + [userId] + ); + cache.delete(this.cacheKey(userId)); + logger.info(`토큰 무효화: ${userId}`); + } catch (error) { + logger.error(`토큰 무효화 실패: ${userId}`, { error }); + } + } + + /** + * 여러 사용자의 토큰 일괄 무효화 + */ + static async invalidateMultipleUserTokens(userIds: string[]): Promise { + if (userIds.length === 0) return; + try { + const placeholders = userIds.map((_, i) => `$${i + 1}`).join(", "); + await query( + `UPDATE user_info SET token_version = COALESCE(token_version, 0) + 1 WHERE user_id IN (${placeholders})`, + userIds + ); + userIds.forEach((id) => cache.delete(this.cacheKey(id))); + logger.info(`토큰 일괄 무효화: ${userIds.length}명`); + } catch (error) { + logger.error(`토큰 일괄 무효화 실패`, { error, userIds }); + } + } + + /** + * 현재 token_version 조회 (캐시 사용) + */ + static async getUserTokenVersion(userId: string): Promise { + const cacheKey = this.cacheKey(userId); + const cached = cache.get(cacheKey); + if (cached !== null) { + return cached; + } + + try { + const result = await query<{ token_version: number | null }>( + `SELECT token_version FROM user_info WHERE user_id = $1`, + [userId] + ); + const version = result.length > 0 ? (result[0].token_version ?? 0) : 0; + cache.set(cacheKey, version, TOKEN_VERSION_CACHE_TTL); + return version; + } catch (error) { + logger.error(`token_version 조회 실패: ${userId}`, { error }); + return 0; + } + } +} diff --git a/backend-node/src/types/auth.ts b/backend-node/src/types/auth.ts index 6abd1e39..e360c01a 100644 --- a/backend-node/src/types/auth.ts +++ b/backend-node/src/types/auth.ts @@ -64,6 +64,7 @@ export interface PersonBean { companyName?: string; // 회사명 추가 photo?: string; locale?: string; + tokenVersion?: number; // JWT 토큰 무효화용 버전 // 권한 레벨 정보 (3단계 체계) isSuperAdmin?: boolean; // 최고 관리자 (company_code === '*' && userType === 'SUPER_ADMIN') isCompanyAdmin?: boolean; // 회사 관리자 (userType === 'COMPANY_ADMIN') @@ -98,6 +99,7 @@ export interface JwtPayload { companyName?: string; // 회사명 추가 userType?: string; userTypeName?: string; + tokenVersion?: number; // JWT 토큰 무효화용 버전 iat?: number; exp?: number; aud?: string; diff --git a/backend-node/src/utils/jwtUtils.ts b/backend-node/src/utils/jwtUtils.ts index 44f75cbc..aba3bf68 100644 --- a/backend-node/src/utils/jwtUtils.ts +++ b/backend-node/src/utils/jwtUtils.ts @@ -20,6 +20,7 @@ export class JwtUtils { companyName: userInfo.companyName, // 회사명 추가 userType: userInfo.userType, userTypeName: userInfo.userTypeName, + tokenVersion: userInfo.tokenVersion ?? 0, }; return jwt.sign(payload, config.jwt.secret, { diff --git a/frontend/app/(main)/production/plan-management/page.tsx b/frontend/app/(main)/production/plan-management/page.tsx index 2d5dcd45..51f7af14 100644 --- a/frontend/app/(main)/production/plan-management/page.tsx +++ b/frontend/app/(main)/production/plan-management/page.tsx @@ -582,8 +582,22 @@ export default function ProductionPlanManagementPage() { if (!ok) return; try { - await Promise.all(plannedIds.map((id) => deletePlan(id))); - toast.success(`${plannedIds.length}건의 계획이 삭제되었습니다`); + const results = await Promise.allSettled(plannedIds.map((id) => deletePlan(id))); + const failedIds = plannedIds.filter((_, i) => results[i].status === "rejected"); + const succeededCount = plannedIds.length - failedIds.length; + + if (failedIds.length === plannedIds.length) { + // 전부 삭제 실패 + toast.error(`${failedIds.length}건 모두 삭제에 실패했습니다. 다시 시도해주세요.`); + } else if (failedIds.length > 0) { + // 일부 삭제 실패 + toast.warning( + `${succeededCount}건 삭제 완료, ${failedIds.length}건 삭제 실패. 실패 항목을 다시 시도해주세요.` + ); + } else { + // 전부 성공 + toast.success(`${plannedIds.length}건의 계획이 삭제되었습니다`); + } fetchPlans(); } catch (err: any) { toast.error("삭제 실패: " + (err.message || "")); diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index bd935b63..427af1bb 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -457,6 +457,13 @@ apiClient.interceptors.response.use( } } + // TOKEN_INVALIDATED → 재로그인 필요 (갱신 시도 없이 즉시) + if (errorCode === "TOKEN_INVALIDATED") { + authLog("REDIRECT_TO_LOGIN", `토큰 무효화 (보안 정책 변경) → 즉시 로그인 리다이렉트 (${url})`); + redirectToLogin(); + return Promise.reject(error); + } + // TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로 authLog("REDIRECT_TO_LOGIN", `복구 불가능한 인증 에러 (${errorCode || "UNKNOWN"}, ${url}) → 로그인 리다이렉트`); redirectToLogin();