Files
vexplor/backend-node/src/services/roleService.ts
kjs 70e040db39 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.
2026-03-25 18:47:50 +09:00

643 lines
18 KiB
TypeScript

import { query, transaction } from "../database/db";
import { logger } from "../utils/logger";
/**
* 권한 그룹 인터페이스
*/
export interface RoleGroup {
objid: number;
authName: string;
authCode: string;
companyCode: string;
status: string;
writer: string;
regdate: Date;
memberCount?: number;
menuCount?: number;
memberNames?: string;
}
/**
* 권한 그룹 멤버 인터페이스
*/
export interface RoleMember {
objid: number;
masterObjid: number;
userId: string;
userName?: string;
deptName?: string;
positionName?: string;
writer: string;
regdate: Date;
}
/**
* 메뉴 권한 인터페이스
*/
export interface MenuPermission {
objid: number;
menuObjid: number;
authObjid: number;
menuName?: string;
createYn: string;
readYn: string;
updateYn: string;
deleteYn: string;
writer: string;
regdate: Date;
}
/**
* 권한 그룹 서비스
*/
export class RoleService {
/**
* 회사별 권한 그룹 목록 조회
* @param companyCode - 회사 코드 (undefined 시 전체 조회)
* @param search - 검색어
*/
static async getRoleGroups(
companyCode?: string,
search?: string
): Promise<RoleGroup[]> {
try {
let sql = `
SELECT
objid,
auth_name AS "authName",
auth_code AS "authCode",
company_code AS "companyCode",
status,
writer,
regdate,
(SELECT COUNT(*) FROM authority_sub_user asu WHERE asu.master_objid = am.objid) AS "memberCount",
(SELECT COUNT(*) FROM rel_menu_auth rma WHERE rma.auth_objid = am.objid) AS "menuCount",
(SELECT STRING_AGG(ui.user_name, ', ' ORDER BY ui.user_name)
FROM authority_sub_user asu
JOIN user_info ui ON asu.user_id = ui.user_id
WHERE asu.master_objid = am.objid) AS "memberNames"
FROM authority_master am
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
// 회사 코드 필터 (companyCode가 undefined면 전체 조회)
if (companyCode) {
sql += ` AND company_code = $${paramIndex}`;
params.push(companyCode);
paramIndex++;
}
// 검색어 필터
if (search && search.trim()) {
sql += ` AND (auth_name ILIKE $${paramIndex} OR auth_code ILIKE $${paramIndex})`;
params.push(`%${search.trim()}%`);
paramIndex++;
}
sql += ` ORDER BY regdate DESC`;
logger.info("권한 그룹 조회 SQL", { sql, params });
const result = await query<RoleGroup>(sql, params);
logger.info("권한 그룹 조회 결과", { count: result.length });
return result;
} catch (error) {
logger.error("권한 그룹 목록 조회 실패", { error, companyCode, search });
throw error;
}
}
/**
* 권한 그룹 상세 조회
*/
static async getRoleGroupById(objid: number): Promise<RoleGroup | null> {
try {
const sql = `
SELECT
objid,
auth_name AS "authName",
auth_code AS "authCode",
company_code AS "companyCode",
status,
writer,
regdate
FROM authority_master
WHERE objid = $1
`;
const result = await query<RoleGroup>(sql, [objid]);
return result.length > 0 ? result[0] : null;
} catch (error) {
logger.error("권한 그룹 상세 조회 실패", { error, objid });
throw error;
}
}
/**
* 권한 그룹 생성
*/
static async createRoleGroup(data: {
authName: string;
authCode: string;
companyCode: string;
writer: string;
}): Promise<RoleGroup> {
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",
company_code AS "companyCode", status, writer, regdate
`;
const result = await query<RoleGroup>(sql, [
data.authName,
data.authCode,
data.companyCode,
data.writer,
]);
logger.info("권한 그룹 생성 성공", {
objid: result[0].objid,
authName: data.authName,
});
return result[0];
} catch (error) {
logger.error("권한 그룹 생성 실패", { error, data });
throw error;
}
}
/**
* 권한 그룹 수정
*/
static async updateRoleGroup(
objid: number,
data: {
authName?: string;
authCode?: string;
status?: string;
}
): Promise<RoleGroup> {
try {
const updates: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (data.authName !== undefined) {
updates.push(`auth_name = $${paramIndex}`);
params.push(data.authName);
paramIndex++;
}
if (data.authCode !== undefined) {
updates.push(`auth_code = $${paramIndex}`);
params.push(data.authCode);
paramIndex++;
}
if (data.status !== undefined) {
updates.push(`status = $${paramIndex}`);
params.push(data.status);
paramIndex++;
}
if (updates.length === 0) {
throw new Error("수정할 데이터가 없습니다");
}
params.push(objid);
const sql = `
UPDATE authority_master
SET ${updates.join(", ")}
WHERE objid = $${paramIndex}
RETURNING objid, auth_name AS "authName", auth_code AS "authCode",
company_code AS "companyCode", status, writer, regdate
`;
const result = await query<RoleGroup>(sql, params);
if (result.length === 0) {
throw new Error("권한 그룹을 찾을 수 없습니다");
}
logger.info("권한 그룹 수정 성공", { objid, updates });
return result[0];
} catch (error) {
logger.error("권한 그룹 수정 실패", { error, objid, data });
throw error;
}
}
/**
* 권한 그룹 삭제
*/
static async deleteRoleGroup(objid: number): Promise<void> {
try {
// CASCADE로 연결된 데이터도 함께 삭제됨 (authority_sub_user, rel_menu_auth)
await query("DELETE FROM authority_master WHERE objid = $1", [objid]);
logger.info("권한 그룹 삭제 성공", { objid });
} catch (error) {
logger.error("권한 그룹 삭제 실패", { error, objid });
throw error;
}
}
/**
* 권한 그룹 멤버 목록 조회
*/
static async getRoleMembers(masterObjid: number): Promise<RoleMember[]> {
try {
const sql = `
SELECT
asu.objid,
asu.master_objid AS "masterObjid",
asu.user_id AS "userId",
ui.user_name AS "userName",
ui.dept_name AS "deptName",
ui.position_name AS "positionName",
asu.writer,
asu.regdate
FROM authority_sub_user asu
JOIN user_info ui ON asu.user_id = ui.user_id
WHERE asu.master_objid = $1
ORDER BY ui.user_name
`;
const result = await query<RoleMember>(sql, [masterObjid]);
return result;
} catch (error) {
logger.error("권한 그룹 멤버 조회 실패", { error, masterObjid });
throw error;
}
}
/**
* 권한 그룹 멤버 추가 (여러 명)
*/
static async addRoleMembers(
masterObjid: number,
userIds: string[],
writer: string
): Promise<void> {
try {
// 이미 존재하는 멤버 제외
const existingSql = `
SELECT user_id
FROM authority_sub_user
WHERE master_objid = $1 AND user_id = ANY($2)
`;
const existing = await query<{ user_id: string }>(existingSql, [
masterObjid,
userIds,
]);
const existingIds = new Set(
existing.map((row: { user_id: string }) => row.user_id)
);
const newUserIds = userIds.filter((userId) => !existingIds.has(userId));
if (newUserIds.length === 0) {
logger.info("추가할 신규 멤버가 없습니다", { masterObjid, userIds });
return;
}
// 배치 삽입
const values = newUserIds
.map(
(_, index) =>
`(nextval('seq_authority_sub_user'), $1, $${index + 2}, $${newUserIds.length + 2}, NOW())`
)
.join(", ");
const sql = `
INSERT INTO authority_sub_user (objid, master_objid, user_id, writer, regdate)
VALUES ${values}
`;
await query(sql, [masterObjid, ...newUserIds, writer]);
// 히스토리 기록
for (const userId of newUserIds) {
await this.insertAuthorityHistory(masterObjid, userId, "ADD", writer);
}
logger.info("권한 그룹 멤버 추가 성공", {
masterObjid,
count: newUserIds.length,
});
} catch (error) {
logger.error("권한 그룹 멤버 추가 실패", { error, masterObjid, userIds });
throw error;
}
}
/**
* 권한 그룹 멤버 제거 (여러 명)
*/
static async removeRoleMembers(
masterObjid: number,
userIds: string[],
writer: string
): Promise<void> {
try {
await query(
"DELETE FROM authority_sub_user WHERE master_objid = $1 AND user_id = ANY($2)",
[masterObjid, userIds]
);
// 히스토리 기록
for (const userId of userIds) {
await this.insertAuthorityHistory(
masterObjid,
userId,
"REMOVE",
writer
);
}
logger.info("권한 그룹 멤버 제거 성공", {
masterObjid,
count: userIds.length,
});
} catch (error) {
logger.error("권한 그룹 멤버 제거 실패", { error, masterObjid, userIds });
throw error;
}
}
/**
* 권한 히스토리 기록
*/
private static async insertAuthorityHistory(
masterObjid: number,
userId: string,
historyType: "ADD" | "REMOVE",
writer: string
): Promise<void> {
try {
const sql = `
INSERT INTO authority_master_history
(objid, parent_objid, parent_name, parent_code, user_id, active, history_type, writer, reg_date)
SELECT
nextval('seq_authority_master'),
$1,
am.auth_name,
am.auth_code,
$2,
am.status,
$3,
$4,
NOW()
FROM authority_master am
WHERE am.objid = $1
`;
await query(sql, [masterObjid, userId, historyType, writer]);
} catch (error) {
logger.error("권한 히스토리 기록 실패", {
error,
masterObjid,
userId,
historyType,
});
// 히스토리 기록 실패는 메인 작업을 중단하지 않음
}
}
/**
* 메뉴 권한 목록 조회
*/
static async getMenuPermissions(
authObjid: number
): Promise<MenuPermission[]> {
try {
const sql = `
SELECT
rma.objid,
rma.menu_objid AS "menuObjid",
rma.auth_objid AS "authObjid",
mi.menu_name_kor AS "menuName",
mi.menu_code AS "menuCode",
mi.menu_url AS "menuUrl",
rma.create_yn AS "createYn",
rma.read_yn AS "readYn",
rma.update_yn AS "updateYn",
rma.delete_yn AS "deleteYn",
rma.execute_yn AS "executeYn",
rma.export_yn AS "exportYn",
rma.writer,
rma.regdate
FROM rel_menu_auth rma
LEFT JOIN menu_info mi ON rma.menu_objid = mi.objid
WHERE rma.auth_objid = $1
ORDER BY mi.menu_name_kor
`;
const result = await query<MenuPermission>(sql, [authObjid]);
return result;
} catch (error) {
logger.error("메뉴 권한 조회 실패", { error, authObjid });
throw error;
}
}
/**
* 메뉴 권한 설정 (여러 메뉴)
*/
static async setMenuPermissions(
authObjid: number,
permissions: Array<{
menuObjid: number;
createYn: string;
readYn: string;
updateYn: string;
deleteYn: string;
}>,
writer: string
): Promise<void> {
try {
await transaction(async (client) => {
// 기존 권한 삭제
await client.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,
]);
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,
count: permissions.length,
});
} catch (error) {
logger.error("메뉴 권한 설정 실패", { error, authObjid, permissions });
throw error;
}
}
/**
* 사용자가 속한 권한 그룹 목록 조회
*/
static async getUserRoleGroups(
userId: string,
companyCode: string
): Promise<RoleGroup[]> {
try {
const sql = `
SELECT
am.objid,
am.auth_name AS "authName",
am.auth_code AS "authCode",
am.company_code AS "companyCode",
am.status,
am.writer,
am.regdate
FROM authority_master am
JOIN authority_sub_user asu ON am.objid = asu.master_objid
WHERE asu.user_id = $1
AND am.company_code = $2
AND am.status = 'active'
ORDER BY am.auth_name
`;
const result = await query<RoleGroup>(sql, [userId, companyCode]);
return result;
} catch (error) {
logger.error("사용자 권한 그룹 조회 실패", {
error,
userId,
companyCode,
});
throw error;
}
}
/**
* 전체 메뉴 목록 조회 (권한 설정용)
*
* @param companyCode - 회사 코드
* - undefined: 최고 관리자 - 모든 회사의 모든 메뉴 조회
* - "*": 최고 관리자의 공통 메뉴만 조회 (최고 관리자 전용)
* - "COMPANY_X": 해당 회사 메뉴만 조회 (공통 메뉴 제외)
*
* 중요:
* - 공통 메뉴(company_code = "*")는 최고 관리자 전용 메뉴입니다.
* - menu_type = 2 (화면)는 제외하고 메뉴만 조회합니다.
*/
static async getAllMenus(companyCode?: string): Promise<any[]> {
try {
logger.info("🔍 전체 메뉴 목록 조회 시작", { companyCode });
let whereConditions: string[] = [
"status = 'active'",
"menu_type != 2" // 화면 제외, 메뉴만 조회
];
const params: any[] = [];
let paramIndex = 1;
// 회사 코드에 따른 필터링
if (companyCode === undefined) {
// 최고 관리자: 모든 메뉴 조회
logger.info("📋 최고 관리자 모드: 모든 메뉴 조회");
} else if (companyCode === "*") {
// 공통 메뉴만 조회
whereConditions.push(`company_code = $${paramIndex}`);
params.push("*");
paramIndex++;
logger.info("📋 공통 메뉴만 조회");
} else {
// 특정 회사: 해당 회사 메뉴 + 공통 메뉴 조회
whereConditions.push(`(company_code = $${paramIndex} OR company_code = '*')`);
params.push(companyCode);
paramIndex++;
logger.info("📋 회사별 필터 적용 (해당 회사 + 공통 메뉴)", { companyCode });
}
const whereClause = whereConditions.join(" AND ");
const sql = `
SELECT
objid,
menu_name_kor AS "menuName",
menu_name_eng AS "menuNameEng",
menu_code AS "menuCode",
menu_url AS "menuUrl",
CAST(menu_type AS TEXT) AS "menuType",
parent_obj_id AS "parentObjid",
seq AS "sortOrder",
company_code AS "companyCode"
FROM menu_info
WHERE ${whereClause}
ORDER BY
CASE
WHEN parent_obj_id = 0 OR parent_obj_id IS NULL THEN 0
ELSE 1
END,
seq,
menu_name_kor
`;
logger.info("🔍 SQL 쿼리 실행", {
whereClause,
params,
sql: sql.substring(0, 200) + "...",
});
const result = await query<any>(sql, params);
logger.info("✅ 메뉴 목록 조회 성공", {
count: result.length,
companyCode: companyCode || "전체",
companyCodes: [...new Set(result.map((m) => m.companyCode))],
menus: result.slice(0, 5).map((m) => ({
objid: m.objid,
name: m.menuName,
code: m.menuCode,
companyCode: m.companyCode,
})),
});
return result;
} catch (error) {
logger.error("❌ 메뉴 목록 조회 실패", { error, companyCode });
throw error;
}
}
}