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();