import { Pool, QueryResult } from "pg"; import axios, { AxiosResponse } from "axios"; import https from "https"; import { getPool } from "../database/db"; import logger from "../utils/logger"; import { ExternalRestApiConnection, ExternalRestApiConnectionFilter, RestApiTestRequest, RestApiTestResult, AuthType, } from "../types/externalRestApiTypes"; import { ApiResponse } from "../types/common"; import crypto from "crypto"; const pool = getPool(); // 암호화 설정 const ENCRYPTION_KEY = process.env.DB_PASSWORD_SECRET || "default-secret-key-change-in-production"; const ALGORITHM = "aes-256-gcm"; export class ExternalRestApiConnectionService { /** * REST API 연결 목록 조회 */ static async getConnections( filter: ExternalRestApiConnectionFilter = {}, userCompanyCode?: string ): Promise> { try { let query = ` SELECT id, connection_name, description, base_url, endpoint_path, default_headers, default_method, -- DB 스키마의 컬럼명은 default_request_body 기준이고 -- 코드에서는 default_body 필드로 사용하기 위해 alias 처리 default_request_body AS default_body, auth_type, auth_config, timeout, retry_count, retry_delay, company_code, is_active, created_date, created_by, updated_date, updated_by, last_test_date, last_test_result, last_test_message FROM external_rest_api_connections WHERE 1=1 `; const params: any[] = []; let paramIndex = 1; // 회사별 필터링 (최고 관리자가 아닌 경우 필수) if (userCompanyCode && userCompanyCode !== "*") { query += ` AND company_code = $${paramIndex}`; params.push(userCompanyCode); paramIndex++; logger.info(`회사별 REST API 연결 필터링: ${userCompanyCode}`); } else if (userCompanyCode === "*") { logger.info(`최고 관리자: 모든 REST API 연결 조회`); // 필터가 있으면 적용 if (filter.company_code) { query += ` AND company_code = $${paramIndex}`; params.push(filter.company_code); paramIndex++; } } else { // userCompanyCode가 없는 경우 (하위 호환성) if (filter.company_code) { query += ` AND company_code = $${paramIndex}`; params.push(filter.company_code); paramIndex++; } } // 활성 상태 필터 if (filter.is_active) { query += ` AND is_active = $${paramIndex}`; params.push(filter.is_active); paramIndex++; } // 인증 타입 필터 if (filter.auth_type) { query += ` AND auth_type = $${paramIndex}`; params.push(filter.auth_type); paramIndex++; } // 검색어 필터 (연결명, 설명, URL) if (filter.search) { query += ` AND ( connection_name ILIKE $${paramIndex} OR description ILIKE $${paramIndex} OR base_url ILIKE $${paramIndex} )`; params.push(`%${filter.search}%`); paramIndex++; } query += ` ORDER BY created_date DESC`; const result: QueryResult = await pool.query(query, params); // 민감 정보 복호화 const connections = result.rows.map((row: any) => ({ ...row, auth_config: row.auth_config ? this.decryptSensitiveData(row.auth_config) : null, })); return { success: true, data: connections, message: `${connections.length}개의 연결을 조회했습니다.`, }; } catch (error) { logger.error("REST API 연결 목록 조회 오류:", error); return { success: false, message: "연결 목록 조회에 실패했습니다.", error: { code: "FETCH_ERROR", details: error instanceof Error ? error.message : "알 수 없는 오류", }, }; } } /** * REST API 연결 상세 조회 */ static async getConnectionById( id: number, userCompanyCode?: string ): Promise> { try { let query = ` SELECT id, connection_name, description, base_url, endpoint_path, default_headers, default_method, default_request_body AS default_body, auth_type, auth_config, timeout, retry_count, retry_delay, company_code, is_active, created_date, created_by, updated_date, updated_by, last_test_date, last_test_result, last_test_message FROM external_rest_api_connections WHERE id = $1 `; const params: any[] = [id]; // 회사별 필터링 (최고 관리자가 아닌 경우) if (userCompanyCode && userCompanyCode !== "*") { query += ` AND company_code = $2`; params.push(userCompanyCode); } const result: QueryResult = await pool.query(query, params); if (result.rows.length === 0) { return { success: false, message: "연결을 찾을 수 없거나 권한이 없습니다.", }; } const connection = result.rows[0]; connection.auth_config = connection.auth_config ? this.decryptSensitiveData(connection.auth_config) : null; return { success: true, data: connection, message: "연결을 조회했습니다.", }; } catch (error) { logger.error("REST API 연결 상세 조회 오류:", error); return { success: false, message: "연결 조회에 실패했습니다.", error: { code: "FETCH_ERROR", details: error instanceof Error ? error.message : "알 수 없는 오류", }, }; } } /** * REST API 연결 생성 */ static async createConnection( data: ExternalRestApiConnection ): Promise> { try { // 유효성 검증 this.validateConnectionData(data); // 민감 정보 암호화 const encryptedAuthConfig = data.auth_config ? this.encryptSensitiveData(data.auth_config) : null; const query = ` INSERT INTO external_rest_api_connections ( connection_name, description, base_url, endpoint_path, default_headers, default_method, default_request_body, auth_type, auth_config, timeout, retry_count, retry_delay, company_code, is_active, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING * `; const params = [ data.connection_name, data.description || null, data.base_url, data.endpoint_path || null, JSON.stringify(data.default_headers || {}), data.default_method || "GET", data.default_body || null, data.auth_type, encryptedAuthConfig ? JSON.stringify(encryptedAuthConfig) : null, data.timeout || 30000, data.retry_count || 0, data.retry_delay || 1000, data.company_code || "*", data.is_active || "Y", data.created_by || "system", ]; const result: QueryResult = await pool.query(query, params); logger.info(`REST API 연결 생성 성공: ${data.connection_name}`); return { success: true, data: result.rows[0], message: "연결이 생성되었습니다.", }; } catch (error: any) { logger.error("REST API 연결 생성 오류:", error); // 중복 키 오류 처리 if (error.code === "23505") { return { success: false, message: "이미 존재하는 연결명입니다.", }; } return { success: false, message: "연결 생성에 실패했습니다.", error: { code: "CREATE_ERROR", details: error instanceof Error ? error.message : "알 수 없는 오류", }, }; } } /** * REST API 연결 수정 */ static async updateConnection( id: number, data: Partial, userCompanyCode?: string ): Promise> { try { // 기존 연결 확인 (회사 코드로 권한 체크) const existing = await this.getConnectionById(id, userCompanyCode); if (!existing.success) { return existing; } // 민감 정보 암호화 const encryptedAuthConfig = data.auth_config ? this.encryptSensitiveData(data.auth_config) : undefined; const updateFields: string[] = []; const params: any[] = []; let paramIndex = 1; if (data.connection_name !== undefined) { updateFields.push(`connection_name = $${paramIndex}`); params.push(data.connection_name); paramIndex++; } if (data.description !== undefined) { updateFields.push(`description = $${paramIndex}`); params.push(data.description); paramIndex++; } if (data.base_url !== undefined) { updateFields.push(`base_url = $${paramIndex}`); params.push(data.base_url); paramIndex++; } if (data.endpoint_path !== undefined) { updateFields.push(`endpoint_path = $${paramIndex}`); params.push(data.endpoint_path); paramIndex++; } if (data.default_headers !== undefined) { updateFields.push(`default_headers = $${paramIndex}`); params.push(JSON.stringify(data.default_headers)); paramIndex++; } if (data.default_method !== undefined) { updateFields.push(`default_method = $${paramIndex}`); params.push(data.default_method); paramIndex++; } if (data.default_body !== undefined) { updateFields.push(`default_request_body = $${paramIndex}`); params.push(data.default_body); paramIndex++; } if (data.auth_type !== undefined) { updateFields.push(`auth_type = $${paramIndex}`); params.push(data.auth_type); paramIndex++; } if (encryptedAuthConfig !== undefined) { updateFields.push(`auth_config = $${paramIndex}`); params.push(JSON.stringify(encryptedAuthConfig)); paramIndex++; } if (data.timeout !== undefined) { updateFields.push(`timeout = $${paramIndex}`); params.push(data.timeout); paramIndex++; } if (data.retry_count !== undefined) { updateFields.push(`retry_count = $${paramIndex}`); params.push(data.retry_count); paramIndex++; } if (data.retry_delay !== undefined) { updateFields.push(`retry_delay = $${paramIndex}`); params.push(data.retry_delay); paramIndex++; } if (data.is_active !== undefined) { updateFields.push(`is_active = $${paramIndex}`); params.push(data.is_active); paramIndex++; } if (data.updated_by !== undefined) { updateFields.push(`updated_by = $${paramIndex}`); params.push(data.updated_by); paramIndex++; } updateFields.push(`updated_date = NOW()`); params.push(id); const query = ` UPDATE external_rest_api_connections SET ${updateFields.join(", ")} WHERE id = $${paramIndex} RETURNING * `; const result: QueryResult = await pool.query(query, params); logger.info(`REST API 연결 수정 성공: ID ${id}`); return { success: true, data: result.rows[0], message: "연결이 수정되었습니다.", }; } catch (error: any) { logger.error("REST API 연결 수정 오류:", error); if (error.code === "23505") { return { success: false, message: "이미 존재하는 연결명입니다.", }; } return { success: false, message: "연결 수정에 실패했습니다.", error: { code: "UPDATE_ERROR", details: error instanceof Error ? error.message : "알 수 없는 오류", }, }; } } /** * REST API 연결 삭제 */ static async deleteConnection( id: number, userCompanyCode?: string ): Promise> { try { let query = ` DELETE FROM external_rest_api_connections WHERE id = $1 `; const params: any[] = [id]; // 회사별 필터링 (최고 관리자가 아닌 경우) if (userCompanyCode && userCompanyCode !== "*") { query += ` AND company_code = $2`; params.push(userCompanyCode); } query += ` RETURNING connection_name`; const result: QueryResult = await pool.query(query, params); if (result.rows.length === 0) { return { success: false, message: "연결을 찾을 수 없거나 권한이 없습니다.", }; } logger.info( `REST API 연결 삭제 성공: ${result.rows[0].connection_name} (회사: ${userCompanyCode || "전체"})` ); return { success: true, message: "연결이 삭제되었습니다.", }; } catch (error) { logger.error("REST API 연결 삭제 오류:", error); return { success: false, message: "연결 삭제에 실패했습니다.", error: { code: "DELETE_ERROR", details: error instanceof Error ? error.message : "알 수 없는 오류", }, }; } } /** * REST API 연결 테스트 (테스트 요청 데이터 기반) */ static async testConnection( testRequest: RestApiTestRequest, userCompanyCode?: string ): Promise { const startTime = Date.now(); try { // 헤더 구성 const headers = { ...testRequest.headers }; // 인증 헤더 추가 if (testRequest.auth_type === "db-token") { const cfg = testRequest.auth_config || {}; const { dbTableName, dbValueColumn, dbWhereColumn, dbWhereValue, dbHeaderName, dbHeaderTemplate, } = cfg; if (!dbTableName || !dbValueColumn) { throw new Error("DB 토큰 설정이 올바르지 않습니다."); } if (!userCompanyCode) { throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다."); } const hasWhereColumn = !!dbWhereColumn; const hasWhereValue = dbWhereValue !== undefined && dbWhereValue !== null && dbWhereValue !== ""; // where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함 if (hasWhereColumn !== hasWhereValue) { throw new Error( "DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다." ); } // 식별자 검증 (간단한 화이트리스트) const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/; if ( !identifierRegex.test(dbTableName) || !identifierRegex.test(dbValueColumn) || (hasWhereColumn && !identifierRegex.test(dbWhereColumn as string)) ) { throw new Error( "DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다." ); } let sql = ` SELECT ${dbValueColumn} AS token_value FROM ${dbTableName} WHERE company_code = $1 `; const params: any[] = [userCompanyCode]; if (hasWhereColumn && hasWhereValue) { sql += ` AND ${dbWhereColumn} = $2`; params.push(dbWhereValue); } sql += ` ORDER BY updated_date DESC LIMIT 1 `; const tokenResult: QueryResult = await pool.query(sql, params); if (tokenResult.rowCount === 0) { throw new Error("DB에서 토큰을 찾을 수 없습니다."); } const tokenValue = tokenResult.rows[0]["token_value"]; const headerName = dbHeaderName || "Authorization"; const template = dbHeaderTemplate || "Bearer {{value}}"; headers[headerName] = template.replace("{{value}}", tokenValue); } else if ( testRequest.auth_type === "bearer" && testRequest.auth_config?.token ) { headers["Authorization"] = `Bearer ${testRequest.auth_config.token}`; } else if (testRequest.auth_type === "basic" && testRequest.auth_config) { const credentials = Buffer.from( `${testRequest.auth_config.username}:${testRequest.auth_config.password}` ).toString("base64"); headers["Authorization"] = `Basic ${credentials}`; } else if ( testRequest.auth_type === "api-key" && testRequest.auth_config ) { if (testRequest.auth_config.keyLocation === "header") { headers[testRequest.auth_config.keyName] = testRequest.auth_config.keyValue; } } // URL 구성 let url = testRequest.base_url; if (testRequest.endpoint) { url = testRequest.endpoint.startsWith("/") ? `${testRequest.base_url}${testRequest.endpoint}` : `${testRequest.base_url}/${testRequest.endpoint}`; } // API Key가 쿼리에 있는 경우 if ( testRequest.auth_type === "api-key" && testRequest.auth_config?.keyLocation === "query" && testRequest.auth_config?.keyName && testRequest.auth_config?.keyValue ) { const separator = url.includes("?") ? "&" : "?"; url = `${url}${separator}${testRequest.auth_config.keyName}=${testRequest.auth_config.keyValue}`; } logger.info( `REST API 연결 테스트: ${testRequest.method || "GET"} ${url}` ); // Body 처리 let body: any = undefined; if (testRequest.body) { // 이미 문자열이면 그대로, 객체면 JSON 문자열로 변환 if (typeof testRequest.body === "string") { body = testRequest.body; } else { body = JSON.stringify(testRequest.body); } // Content-Type 헤더가 없으면 기본적으로 application/json 추가 const hasContentType = Object.keys(headers).some( (k) => k.toLowerCase() === "content-type" ); if (!hasContentType) { headers["Content-Type"] = "application/json"; } } // HTTP 요청 실행 (배치관리 RestApiConnector와 동일하게 TLS 검증 우회 옵션 적용) const httpsAgent = new https.Agent({ // 배치관리와 동일하게, 일부 내부망/자체 서명 인증서를 사용하는 API를 위해 // 인증서 검증을 비활성화한다. // 공개 인터넷용 API에는 신중히 사용해야 함. rejectUnauthorized: false, }); const requestConfig = { url, method: (testRequest.method || "GET") as any, headers, data: body, httpsAgent, timeout: testRequest.timeout || 30000, // 4xx/5xx 도 예외가 아니라 응답 객체로 처리 validateStatus: () => true, }; // 요청 상세 로그 (민감 정보는 최소화) logger.info( `REST API 연결 테스트 요청 상세: ${JSON.stringify({ method: requestConfig.method, url: requestConfig.url, headers: { ...requestConfig.headers, // Authorization 헤더는 마스킹 Authorization: requestConfig.headers?.Authorization ? "***masked***" : undefined, }, hasBody: !!body, })}` ); const response: AxiosResponse = await axios.request(requestConfig); const responseTime = Date.now() - startTime; // axios는 response.data에 이미 파싱된 응답 본문을 담아준다. // JSON이 아니어도 그대로 내려보내서 프론트에서 확인할 수 있게 한다. const responseData = response.data ?? null; return { success: response.status >= 200 && response.status < 300, message: response.status >= 200 && response.status < 300 ? "연결 성공" : `연결 실패 (${response.status} ${response.statusText})`, response_time: responseTime, status_code: response.status, response_data: responseData, }; } catch (error) { const responseTime = Date.now() - startTime; logger.error("REST API 연결 테스트 오류:", error); return { success: false, message: "연결 실패", response_time: responseTime, error_details: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** * REST API 연결 테스트 (ID 기반) */ static async testConnectionById( id: number, endpoint?: string ): Promise { try { const connectionResult = await this.getConnectionById(id); if (!connectionResult.success || !connectionResult.data) { return { success: false, message: "연결을 찾을 수 없습니다.", }; } const connection = connectionResult.data; // 리스트에서 endpoint를 넘기지 않으면, // 저장된 endpoint_path를 기본 엔드포인트로 사용 const effectiveEndpoint = endpoint || connection.endpoint_path || undefined; const testRequest: RestApiTestRequest = { id: connection.id, base_url: connection.base_url, endpoint: effectiveEndpoint, method: (connection.default_method as any) || "GET", // 기본 메서드 적용 headers: connection.default_headers, body: connection.default_body, // 기본 바디 적용 auth_type: connection.auth_type, auth_config: connection.auth_config, timeout: connection.timeout, }; const result = await this.testConnection( testRequest, connection.company_code ); // 테스트 결과 저장 await pool.query( ` UPDATE external_rest_api_connections SET last_test_date = NOW(), last_test_result = $1, last_test_message = $2 WHERE id = $3 `, [result.success ? "Y" : "N", result.message, id] ); return result; } catch (error) { logger.error("REST API 연결 테스트 (ID) 오류:", error); return { success: false, message: "연결 테스트에 실패했습니다.", error_details: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** * 민감 정보 암호화 */ private static encryptSensitiveData(authConfig: any): any { if (!authConfig) return null; const encrypted = { ...authConfig }; // 암호화 대상 필드 if (encrypted.keyValue) { encrypted.keyValue = this.encrypt(encrypted.keyValue); } if (encrypted.token) { encrypted.token = this.encrypt(encrypted.token); } if (encrypted.password) { encrypted.password = this.encrypt(encrypted.password); } if (encrypted.clientSecret) { encrypted.clientSecret = this.encrypt(encrypted.clientSecret); } return encrypted; } /** * 민감 정보 복호화 */ private static decryptSensitiveData(authConfig: any): any { if (!authConfig) return null; const decrypted = { ...authConfig }; // 복호화 대상 필드 try { if (decrypted.keyValue) { decrypted.keyValue = this.decrypt(decrypted.keyValue); } if (decrypted.token) { decrypted.token = this.decrypt(decrypted.token); } if (decrypted.password) { decrypted.password = this.decrypt(decrypted.password); } if (decrypted.clientSecret) { decrypted.clientSecret = this.decrypt(decrypted.clientSecret); } } catch (error) { logger.warn("민감 정보 복호화 실패 (암호화되지 않은 데이터일 수 있음)"); } return decrypted; } /** * 암호화 헬퍼 */ private static encrypt(text: string): string { const iv = crypto.randomBytes(16); const key = crypto.scryptSync(ENCRYPTION_KEY, "salt", 32); const cipher = crypto.createCipheriv(ALGORITHM, key, iv); let encrypted = cipher.update(text, "utf8", "hex"); encrypted += cipher.final("hex"); const authTag = cipher.getAuthTag(); return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`; } /** * 복호화 헬퍼 */ private static decrypt(text: string): string { const parts = text.split(":"); if (parts.length !== 3) { // 암호화되지 않은 데이터 return text; } const iv = Buffer.from(parts[0], "hex"); const authTag = Buffer.from(parts[1], "hex"); const encryptedText = parts[2]; const key = crypto.scryptSync(ENCRYPTION_KEY, "salt", 32); const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(authTag); let decrypted = decipher.update(encryptedText, "hex", "utf8"); decrypted += decipher.final("utf8"); return decrypted; } /** * 연결 데이터 유효성 검증 */ private static validateConnectionData(data: ExternalRestApiConnection): void { if (!data.connection_name || data.connection_name.trim() === "") { throw new Error("연결명은 필수입니다."); } if (!data.base_url || data.base_url.trim() === "") { throw new Error("기본 URL은 필수입니다."); } // URL 형식 검증 try { new URL(data.base_url); } catch { throw new Error("올바른 URL 형식이 아닙니다."); } // 인증 타입 검증 const validAuthTypes: AuthType[] = [ "none", "api-key", "bearer", "basic", "oauth2", "db-token", ]; if (!validAuthTypes.includes(data.auth_type)) { throw new Error("올바르지 않은 인증 타입입니다."); } } }