- 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.
341 lines
9.7 KiB
TypeScript
341 lines
9.7 KiB
TypeScript
import { Request } from "express";
|
|
import { query, pool } from "../database/db";
|
|
import logger from "../utils/logger";
|
|
|
|
export function getClientIp(req: Request): string {
|
|
const forwarded = req.headers["x-forwarded-for"];
|
|
if (forwarded) {
|
|
const first = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(",")[0];
|
|
return first.trim();
|
|
}
|
|
const realIp = req.headers["x-real-ip"];
|
|
if (realIp) {
|
|
return Array.isArray(realIp) ? realIp[0] : realIp;
|
|
}
|
|
return req.ip || req.socket?.remoteAddress || "unknown";
|
|
}
|
|
|
|
export type AuditAction =
|
|
| "CREATE"
|
|
| "UPDATE"
|
|
| "DELETE"
|
|
| "COPY"
|
|
| "LOGIN"
|
|
| "STATUS_CHANGE"
|
|
| "BATCH_CREATE"
|
|
| "BATCH_UPDATE"
|
|
| "BATCH_DELETE"
|
|
| "DEPT_CHANGE_WARNING";
|
|
|
|
export type AuditResourceType =
|
|
| "MENU"
|
|
| "SCREEN"
|
|
| "SCREEN_LAYOUT"
|
|
| "FLOW"
|
|
| "FLOW_STEP"
|
|
| "USER"
|
|
| "ROLE"
|
|
| "PERMISSION"
|
|
| "COMPANY"
|
|
| "CODE_CATEGORY"
|
|
| "CODE"
|
|
| "DATA"
|
|
| "TABLE"
|
|
| "NUMBERING_RULE"
|
|
| "BATCH"
|
|
| "NODE_FLOW";
|
|
|
|
export interface AuditLogParams {
|
|
companyCode: string;
|
|
userId: string;
|
|
userName?: string;
|
|
action: AuditAction;
|
|
resourceType: AuditResourceType;
|
|
resourceId?: string;
|
|
resourceName?: string;
|
|
tableName?: string;
|
|
summary?: string;
|
|
changes?: {
|
|
before?: Record<string, any>;
|
|
after?: Record<string, any>;
|
|
fields?: string[];
|
|
};
|
|
ipAddress?: string;
|
|
requestPath?: string;
|
|
}
|
|
|
|
export interface AuditLogEntry {
|
|
id: number;
|
|
company_code: string;
|
|
company_name: string | null;
|
|
user_id: string;
|
|
user_name: string | null;
|
|
action: string;
|
|
resource_type: string;
|
|
resource_id: string | null;
|
|
resource_name: string | null;
|
|
table_name: string | null;
|
|
summary: string | null;
|
|
changes: any;
|
|
ip_address: string | null;
|
|
request_path: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface AuditLogFilters {
|
|
companyCode?: string;
|
|
userId?: string;
|
|
resourceType?: string;
|
|
action?: string;
|
|
tableName?: string;
|
|
dateFrom?: string;
|
|
dateTo?: string;
|
|
search?: string;
|
|
page?: number;
|
|
limit?: number;
|
|
}
|
|
|
|
export interface AuditLogStats {
|
|
dailyCounts: Array<{ date: string; count: number }>;
|
|
resourceTypeCounts: Array<{ resource_type: string; count: number }>;
|
|
actionCounts: Array<{ action: string; count: number }>;
|
|
topUsers: Array<{ user_id: string; user_name: string; count: number }>;
|
|
}
|
|
|
|
class AuditLogService {
|
|
/**
|
|
* 감사 로그 1건 기록 (fire-and-forget)
|
|
* 본 작업에 영향을 주지 않도록 에러를 내부에서 처리
|
|
*/
|
|
async log(params: AuditLogParams): Promise<void> {
|
|
try {
|
|
logger.info(`[AuditLog] 기록 시도: ${params.resourceType} / ${params.action} / ${params.resourceName || params.resourceId || "N/A"}`);
|
|
await query(
|
|
`INSERT INTO system_audit_log
|
|
(company_code, user_id, user_name, action, resource_type,
|
|
resource_id, resource_name, table_name, summary, changes,
|
|
ip_address, request_path)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
|
[
|
|
params.companyCode,
|
|
params.userId,
|
|
params.userName || null,
|
|
params.action,
|
|
params.resourceType,
|
|
params.resourceId || null,
|
|
params.resourceName || null,
|
|
params.tableName || null,
|
|
params.summary || null,
|
|
params.changes ? JSON.stringify(params.changes) : null,
|
|
params.ipAddress || null,
|
|
params.requestPath || null,
|
|
]
|
|
);
|
|
logger.info(`[AuditLog] 기록 성공: ${params.resourceType} / ${params.action}`);
|
|
} catch (error: any) {
|
|
logger.error(`[AuditLog] 기록 실패: ${params.resourceType} / ${params.action} - ${error?.message}`, { error, params });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 감사 로그 다건 기록 (배치)
|
|
*/
|
|
async logBatch(entries: AuditLogParams[]): Promise<void> {
|
|
if (entries.length === 0) return;
|
|
try {
|
|
const values = entries
|
|
.map(
|
|
(_, i) =>
|
|
`($${i * 12 + 1}, $${i * 12 + 2}, $${i * 12 + 3}, $${i * 12 + 4}, $${i * 12 + 5}, $${i * 12 + 6}, $${i * 12 + 7}, $${i * 12 + 8}, $${i * 12 + 9}, $${i * 12 + 10}, $${i * 12 + 11}, $${i * 12 + 12})`
|
|
)
|
|
.join(", ");
|
|
|
|
const params = entries.flatMap((e) => [
|
|
e.companyCode,
|
|
e.userId,
|
|
e.userName || null,
|
|
e.action,
|
|
e.resourceType,
|
|
e.resourceId || null,
|
|
e.resourceName || null,
|
|
e.tableName || null,
|
|
e.summary || null,
|
|
e.changes ? JSON.stringify(e.changes) : null,
|
|
e.ipAddress || null,
|
|
e.requestPath || null,
|
|
]);
|
|
|
|
await query(
|
|
`INSERT INTO system_audit_log
|
|
(company_code, user_id, user_name, action, resource_type,
|
|
resource_id, resource_name, table_name, summary, changes,
|
|
ip_address, request_path)
|
|
VALUES ${values}`,
|
|
params
|
|
);
|
|
} catch (error) {
|
|
logger.error("감사 로그 배치 기록 실패 (무시됨)", { error });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 감사 로그 조회 (페이징, 필터)
|
|
*/
|
|
async queryLogs(
|
|
filters: AuditLogFilters,
|
|
isSuperAdmin: boolean = false
|
|
): Promise<{ data: AuditLogEntry[]; total: number }> {
|
|
const conditions: string[] = [];
|
|
const params: any[] = [];
|
|
let paramIndex = 1;
|
|
|
|
if (!isSuperAdmin && filters.companyCode) {
|
|
conditions.push(`sal.company_code = $${paramIndex++}`);
|
|
params.push(filters.companyCode);
|
|
} else if (isSuperAdmin && filters.companyCode) {
|
|
conditions.push(`sal.company_code = $${paramIndex++}`);
|
|
params.push(filters.companyCode);
|
|
}
|
|
|
|
if (filters.userId) {
|
|
conditions.push(`sal.user_id = $${paramIndex++}`);
|
|
params.push(filters.userId);
|
|
}
|
|
if (filters.resourceType) {
|
|
conditions.push(`sal.resource_type = $${paramIndex++}`);
|
|
params.push(filters.resourceType);
|
|
}
|
|
if (filters.action) {
|
|
conditions.push(`sal.action = $${paramIndex++}`);
|
|
params.push(filters.action);
|
|
}
|
|
if (filters.tableName) {
|
|
conditions.push(`sal.table_name = $${paramIndex++}`);
|
|
params.push(filters.tableName);
|
|
}
|
|
if (filters.dateFrom) {
|
|
conditions.push(`sal.created_at >= $${paramIndex++}::timestamptz`);
|
|
params.push(filters.dateFrom);
|
|
}
|
|
if (filters.dateTo) {
|
|
conditions.push(`sal.created_at <= $${paramIndex++}::timestamptz`);
|
|
params.push(filters.dateTo);
|
|
}
|
|
if (filters.search) {
|
|
conditions.push(
|
|
`(sal.summary ILIKE $${paramIndex} OR sal.resource_name ILIKE $${paramIndex} OR sal.user_name ILIKE $${paramIndex})`
|
|
);
|
|
params.push(`%${filters.search}%`);
|
|
paramIndex++;
|
|
}
|
|
|
|
const whereClause =
|
|
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
|
|
const page = filters.page || 1;
|
|
const limit = filters.limit || 50;
|
|
const offset = (page - 1) * limit;
|
|
|
|
const countResult = await query<{ count: string }>(
|
|
`SELECT COUNT(*) as count FROM system_audit_log sal ${whereClause}`,
|
|
params
|
|
);
|
|
const total = parseInt(countResult[0].count, 10);
|
|
|
|
const data = await query<AuditLogEntry>(
|
|
`SELECT sal.*, ci.company_name
|
|
FROM system_audit_log sal
|
|
LEFT JOIN company_mng ci ON sal.company_code = ci.company_code
|
|
${whereClause}
|
|
ORDER BY sal.created_at DESC
|
|
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
|
[...params, limit, offset]
|
|
);
|
|
|
|
const SECURITY_MASK = "(보안 항목 - 값 비공개)";
|
|
const securedTables = ["table_type_columns"];
|
|
|
|
if (!isSuperAdmin) {
|
|
for (const entry of data) {
|
|
if (entry.table_name && securedTables.includes(entry.table_name) && entry.changes) {
|
|
const changes = typeof entry.changes === "string" ? JSON.parse(entry.changes) : entry.changes;
|
|
if (changes.before) {
|
|
for (const key of Object.keys(changes.before)) {
|
|
changes.before[key] = SECURITY_MASK;
|
|
}
|
|
}
|
|
if (changes.after) {
|
|
for (const key of Object.keys(changes.after)) {
|
|
changes.after[key] = SECURITY_MASK;
|
|
}
|
|
}
|
|
entry.changes = changes;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { data, total };
|
|
}
|
|
|
|
/**
|
|
* 통계 조회
|
|
*/
|
|
async getStats(
|
|
companyCode?: string,
|
|
days: number = 30
|
|
): Promise<AuditLogStats> {
|
|
const companyFilter = companyCode
|
|
? "AND company_code = $1"
|
|
: "";
|
|
const params = companyCode ? [companyCode] : [];
|
|
|
|
const dailyCounts = await query<{ date: string; count: number }>(
|
|
`SELECT DATE(created_at) as date, COUNT(*)::int as count
|
|
FROM system_audit_log
|
|
WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter}
|
|
GROUP BY DATE(created_at)
|
|
ORDER BY date DESC`,
|
|
params
|
|
);
|
|
|
|
const resourceTypeCounts = await query<{
|
|
resource_type: string;
|
|
count: number;
|
|
}>(
|
|
`SELECT resource_type, COUNT(*)::int as count
|
|
FROM system_audit_log
|
|
WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter}
|
|
GROUP BY resource_type
|
|
ORDER BY count DESC`,
|
|
params
|
|
);
|
|
|
|
const actionCounts = await query<{ action: string; count: number }>(
|
|
`SELECT action, COUNT(*)::int as count
|
|
FROM system_audit_log
|
|
WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter}
|
|
GROUP BY action
|
|
ORDER BY count DESC`,
|
|
params
|
|
);
|
|
|
|
const topUsers = await query<{
|
|
user_id: string;
|
|
user_name: string;
|
|
count: number;
|
|
}>(
|
|
`SELECT user_id, COALESCE(MAX(user_name), user_id) as user_name, COUNT(*)::int as count
|
|
FROM system_audit_log
|
|
WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter}
|
|
GROUP BY user_id
|
|
ORDER BY count DESC
|
|
LIMIT 10`,
|
|
params
|
|
);
|
|
|
|
return { dailyCounts, resourceTypeCounts, actionCounts, topUsers };
|
|
}
|
|
}
|
|
|
|
export const auditLogService = new AuditLogService();
|