- 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.
643 lines
18 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|