feat: Implement password masking and encryption in data services
- Added a new function `maskPasswordColumns` to mask password fields in data responses, ensuring sensitive information is not exposed. - Integrated password handling in `DynamicFormService` to encrypt new passwords and maintain existing ones when empty values are provided. - Enhanced logging for better tracking of password field updates and masking failures, improving overall security and debugging capabilities.
This commit is contained in:
parent
df04afa5de
commit
b1ec674fa9
|
|
@ -18,6 +18,45 @@ import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool impo
|
||||||
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
|
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
|
||||||
import { v4 as uuidv4 } from "uuid"; // 🆕 UUID 생성
|
import { v4 as uuidv4 } from "uuid"; // 🆕 UUID 생성
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호(password) 타입 컬럼의 값을 빈 문자열로 마스킹
|
||||||
|
* - table_type_columns에서 input_type = 'password'인 컬럼을 조회
|
||||||
|
* - 데이터 응답에서 해당 컬럼 값을 비워서 해시값 노출 방지
|
||||||
|
*/
|
||||||
|
async function maskPasswordColumns(tableName: string, data: any): Promise<any> {
|
||||||
|
try {
|
||||||
|
const passwordCols = await query<{ column_name: string }>(
|
||||||
|
`SELECT DISTINCT column_name FROM table_type_columns
|
||||||
|
WHERE table_name = $1 AND input_type = 'password'`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
if (passwordCols.length === 0) return data;
|
||||||
|
|
||||||
|
const passwordColumnNames = new Set(passwordCols.map(c => c.column_name));
|
||||||
|
|
||||||
|
// 단일 객체 처리
|
||||||
|
const maskRow = (row: any) => {
|
||||||
|
if (!row || typeof row !== "object") return row;
|
||||||
|
const masked = { ...row };
|
||||||
|
for (const col of passwordColumnNames) {
|
||||||
|
if (col in masked) {
|
||||||
|
masked[col] = ""; // 해시값 대신 빈 문자열
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return masked;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data.map(maskRow);
|
||||||
|
}
|
||||||
|
return maskRow(data);
|
||||||
|
} catch (error) {
|
||||||
|
// 마스킹 실패해도 원본 데이터 반환 (서비스 중단 방지)
|
||||||
|
console.warn("⚠️ password 컬럼 마스킹 실패:", error);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface GetTableDataParams {
|
interface GetTableDataParams {
|
||||||
tableName: string;
|
tableName: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|
@ -622,14 +661,14 @@ class DataService {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: normalizedGroupRows, // 🔧 배열로 반환!
|
data: await maskPasswordColumns(tableName, normalizedGroupRows), // 🔧 배열로 반환! + password 마스킹
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: normalizedRows[0], // 그룹핑 없으면 단일 레코드
|
data: await maskPasswordColumns(tableName, normalizedRows[0]), // 그룹핑 없으면 단일 레코드 + password 마스킹
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -648,7 +687,7 @@ class DataService {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: result[0],
|
data: await maskPasswordColumns(tableName, result[0]), // password 마스킹
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error);
|
console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { query, queryOne, transaction, getPool } from "../database/db";
|
||||||
import { EventTriggerService } from "./eventTriggerService";
|
import { EventTriggerService } from "./eventTriggerService";
|
||||||
import { DataflowControlService } from "./dataflowControlService";
|
import { DataflowControlService } from "./dataflowControlService";
|
||||||
import tableCategoryValueService from "./tableCategoryValueService";
|
import tableCategoryValueService from "./tableCategoryValueService";
|
||||||
|
import { PasswordUtils } from "../utils/passwordUtils";
|
||||||
|
|
||||||
export interface FormDataResult {
|
export interface FormDataResult {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -859,6 +860,33 @@ export class DynamicFormService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 비밀번호(password) 타입 컬럼 처리
|
||||||
|
// - 빈 값이면 변경 목록에서 제거 (기존 비밀번호 유지)
|
||||||
|
// - 값이 있으면 암호화 후 저장
|
||||||
|
try {
|
||||||
|
const passwordCols = await query<{ column_name: string }>(
|
||||||
|
`SELECT DISTINCT column_name FROM table_type_columns
|
||||||
|
WHERE table_name = $1 AND input_type = 'password'`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
for (const { column_name } of passwordCols) {
|
||||||
|
if (column_name in changedFields) {
|
||||||
|
const pwValue = changedFields[column_name];
|
||||||
|
if (!pwValue || pwValue === "") {
|
||||||
|
// 빈 값 → 기존 비밀번호 유지 (변경 목록에서 제거)
|
||||||
|
delete changedFields[column_name];
|
||||||
|
console.log(`🔐 비밀번호 필드 ${column_name}: 빈 값이므로 업데이트 스킵 (기존 유지)`);
|
||||||
|
} else {
|
||||||
|
// 값 있음 → 암호화하여 저장
|
||||||
|
changedFields[column_name] = PasswordUtils.encrypt(pwValue);
|
||||||
|
console.log(`🔐 비밀번호 필드 ${column_name}: 새 비밀번호 암호화 완료`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (pwError) {
|
||||||
|
console.warn("⚠️ 비밀번호 컬럼 처리 중 오류:", pwError);
|
||||||
|
}
|
||||||
|
|
||||||
// 변경된 필드가 없으면 업데이트 건너뛰기
|
// 변경된 필드가 없으면 업데이트 건너뛰기
|
||||||
if (Object.keys(changedFields).length === 0) {
|
if (Object.keys(changedFields).length === 0) {
|
||||||
console.log("📋 변경된 필드가 없습니다. 업데이트를 건너뜁니다.");
|
console.log("📋 변경된 필드가 없습니다. 업데이트를 건너뜁니다.");
|
||||||
|
|
|
||||||
|
|
@ -596,10 +596,22 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
const additionalFields = componentConfig.additionalFields || [];
|
const additionalFields = componentConfig.additionalFields || [];
|
||||||
const mainTable = componentConfig.targetTable!;
|
const mainTable = componentConfig.targetTable!;
|
||||||
|
|
||||||
// 수정 모드 감지: URL에 mode=edit가 있으면 수정 모드
|
// 수정 모드 감지 (2가지 방법으로 확인)
|
||||||
|
// 1. URL에 mode=edit 파라미터 확인
|
||||||
|
// 2. 로드된 데이터에 DB id(PK)가 존재하는지 확인
|
||||||
// 수정 모드에서는 항상 deleteOrphans=true (기존 레코드 교체, 복제 방지)
|
// 수정 모드에서는 항상 deleteOrphans=true (기존 레코드 교체, 복제 방지)
|
||||||
const urlParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null;
|
const urlParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null;
|
||||||
const isEditMode = urlParams?.get("mode") === "edit";
|
const urlEditMode = urlParams?.get("mode") === "edit";
|
||||||
|
const dataHasDbId = items.some(item => !!item.originalData?.id);
|
||||||
|
const isEditMode = urlEditMode || dataHasDbId;
|
||||||
|
|
||||||
|
console.log("[SelectedItemsDetailInput] 수정 모드 감지:", {
|
||||||
|
urlEditMode,
|
||||||
|
dataHasDbId,
|
||||||
|
isEditMode,
|
||||||
|
itemCount: items.length,
|
||||||
|
firstItemId: items[0]?.originalData?.id,
|
||||||
|
});
|
||||||
|
|
||||||
// fieldGroup별 sourceTable 분류
|
// fieldGroup별 sourceTable 분류
|
||||||
const groupsByTable = new Map<string, typeof groups>();
|
const groupsByTable = new Map<string, typeof groups>();
|
||||||
|
|
@ -695,6 +707,16 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
// 신규 등록이고 id 없으면 → 기존 레코드 건드리지 않음
|
// 신규 등록이고 id 없으면 → 기존 레코드 건드리지 않음
|
||||||
const mappingHasDbIds = mappingRecords.some((r) => !!r.id);
|
const mappingHasDbIds = mappingRecords.some((r) => !!r.id);
|
||||||
const shouldDeleteOrphans = isEditMode || mappingHasDbIds;
|
const shouldDeleteOrphans = isEditMode || mappingHasDbIds;
|
||||||
|
|
||||||
|
console.log(`[SelectedItemsDetailInput] ${mainTable} 저장:`, {
|
||||||
|
isEditMode,
|
||||||
|
mappingHasDbIds,
|
||||||
|
shouldDeleteOrphans,
|
||||||
|
recordCount: mappingRecords.length,
|
||||||
|
recordIds: mappingRecords.map(r => r.id || "NEW"),
|
||||||
|
parentKeys: itemParentKeys,
|
||||||
|
});
|
||||||
|
|
||||||
// 저장된 매핑 ID를 추적 (디테일 테이블에 mapping_id 주입용)
|
// 저장된 매핑 ID를 추적 (디테일 테이블에 mapping_id 주입용)
|
||||||
let savedMappingIds: string[] = [];
|
let savedMappingIds: string[] = [];
|
||||||
try {
|
try {
|
||||||
|
|
@ -782,6 +804,16 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
|
|
||||||
const priceHasDbIds = priceRecords.some((r) => !!r.id);
|
const priceHasDbIds = priceRecords.some((r) => !!r.id);
|
||||||
const shouldDeleteDetailOrphans = isEditMode || priceHasDbIds;
|
const shouldDeleteDetailOrphans = isEditMode || priceHasDbIds;
|
||||||
|
|
||||||
|
console.log(`[SelectedItemsDetailInput] ${detailTable} 저장:`, {
|
||||||
|
isEditMode,
|
||||||
|
priceHasDbIds,
|
||||||
|
shouldDeleteDetailOrphans,
|
||||||
|
recordCount: priceRecords.length,
|
||||||
|
recordIds: priceRecords.map(r => r.id || "NEW"),
|
||||||
|
parentKeys: itemParentKeys,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const detailResult = await dataApi.upsertGroupedRecords(
|
const detailResult = await dataApi.upsertGroupedRecords(
|
||||||
detailTable,
|
detailTable,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue