- API 감사 로거 추가 - APITesterAuditLogger 클래스 생성
- 회원가입 링크 수정 - href 속성 추가 - Testbed UI 스타일 추가 - CSS/SASS 코드 업데이트
This commit is contained in:
@@ -0,0 +1,294 @@
|
|||||||
|
package com.eactive.apim.portal.apps.apis.filter;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||||
|
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.TextNode;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 테스트베드(/api/call-api) 감사(audit) 로그 기록기.
|
||||||
|
*
|
||||||
|
* <p>logback 의 {@code eapim.portal.apitester.audit} 로거(전용 파일, 1년 보관)로 기록한다.
|
||||||
|
* 요청 1건당 REQ/RES 두 줄을 같은 auditId 로 남긴다.</p>
|
||||||
|
*
|
||||||
|
* <p>마스킹 정책:</p>
|
||||||
|
* <ul>
|
||||||
|
* <li>secret 계열 키(client_secret, password, api_key, authorization 등)의 값은 전체 마스킹</li>
|
||||||
|
* <li>그 외 파라미터/JSON 값은 앞 일부만 남기고 마스킹</li>
|
||||||
|
* <li>JSON 이 아닌 본문은 전체 길이(byte)와 앞 {@value #NON_JSON_PREVIEW_LENGTH}글자만 남기고 마스킹</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class ApiTesterAuditLogger {
|
||||||
|
|
||||||
|
private static final Logger auditLogger = LoggerFactory.getLogger("eapim.portal.apitester.audit");
|
||||||
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
/** 값 전체를 마스킹할 키(소문자 비교) */
|
||||||
|
private static final Set<String> SECRET_KEYS = new HashSet<>(Arrays.asList(
|
||||||
|
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
|
||||||
|
"api_key", "apikey", "access_token", "refresh_token", "authorization"));
|
||||||
|
|
||||||
|
/** 감사 로그에 남길 주요 요청 헤더 화이트리스트 */
|
||||||
|
private static final String[] AUDIT_HEADERS = {
|
||||||
|
"content-type", "accept", "referer", "origin", "x-forwarded-for",
|
||||||
|
"original-api-id", "authorization"};
|
||||||
|
|
||||||
|
/** 마스킹된 JSON 본문 로그 최대 길이(초과분 절단) — 대용량 본문의 로그 파일 비대화 방지 */
|
||||||
|
private static final int JSON_LOG_MAX_LENGTH = 2000;
|
||||||
|
|
||||||
|
/** JSON 이 아닌 본문의 노출 프리뷰 글자 수 */
|
||||||
|
private static final int NON_JSON_PREVIEW_LENGTH = 8;
|
||||||
|
|
||||||
|
private ApiTesterAuditLogger() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** REQ/RES 두 줄을 연결하는 짧은 감사 ID */
|
||||||
|
public static String newAuditId() {
|
||||||
|
return UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 수신 시점 기록. 감사 로그 실패가 프록시 동작을 막지 않도록 예외는 삼킨다.
|
||||||
|
*
|
||||||
|
* @param targetUrl original-url 헤더 값 (없으면 null)
|
||||||
|
* @param gatewayMode 게이트웨이 모드명 (판별 전이면 "-")
|
||||||
|
* @param tokenRequest OAuth 토큰 발급 요청 여부
|
||||||
|
* @param body 이미 읽어 둔 요청 본문 (없으면 null/빈 문자열)
|
||||||
|
*/
|
||||||
|
public static void logRequest(String auditId, HttpServletRequest request, String targetUrl,
|
||||||
|
String gatewayMode, boolean tokenRequest, String body) {
|
||||||
|
try {
|
||||||
|
StringBuilder sb = new StringBuilder(256);
|
||||||
|
sb.append("REQ [").append(auditId).append(']');
|
||||||
|
sb.append(" ip=").append(HttpRequestUtil.getClientIpAddress(request));
|
||||||
|
sb.append(" proxied=").append(HttpRequestUtil.isProxied(request));
|
||||||
|
sb.append(" user=").append(currentUser());
|
||||||
|
sb.append(" method=").append(request.getMethod());
|
||||||
|
sb.append(" mode=").append(gatewayMode);
|
||||||
|
sb.append(" token=").append(tokenRequest);
|
||||||
|
sb.append(" target=").append(targetUrl == null ? "-" : maskQueryValues(sanitize(targetUrl)));
|
||||||
|
sb.append(" ua=\"").append(sanitize(request.getHeader("User-Agent"))).append('"');
|
||||||
|
sb.append(" headers=").append(buildHeaderSummary(request));
|
||||||
|
sb.append(" body=").append(buildBodySummary(request.getContentType(), tokenRequest, body));
|
||||||
|
auditLogger.info(sb.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
auditLogger.warn("REQ [{}] 감사 로그 기록 실패: {}", auditId, e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 처리 완료 시점 기록. type 은 처리 분기(TOKEN_GW/TOKEN_MOCK/SAMPLE/GW/MOCK 등). */
|
||||||
|
public static void logResult(String auditId, int status, String type, long elapsedMillis) {
|
||||||
|
auditLogger.info("RES [{}] status={} type={} elapsedMs={}", auditId, status, type, elapsedMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 요청 정보 구성
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/** 로그인 사용자 식별자(마스킹). 미인증이면 anonymous. */
|
||||||
|
private static String currentUser() {
|
||||||
|
try {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getName())) {
|
||||||
|
return "anonymous";
|
||||||
|
}
|
||||||
|
String name = auth.getName();
|
||||||
|
return name.contains("@") ? StringMaskingUtil.maskEmail(name) : partialMask(name);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 화이트리스트 헤더만 {k:"v"} 형태로 요약. secret 계열 헤더 값은 마스킹. */
|
||||||
|
private static String buildHeaderSummary(HttpServletRequest request) {
|
||||||
|
StringBuilder sb = new StringBuilder("{");
|
||||||
|
boolean first = true;
|
||||||
|
for (String name : AUDIT_HEADERS) {
|
||||||
|
String value = request.getHeader(name);
|
||||||
|
if (value == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!first) {
|
||||||
|
sb.append(", ");
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
sb.append(name).append(":\"").append(maskHeaderValue(name, sanitize(value))).append('"');
|
||||||
|
}
|
||||||
|
return sb.append('}').toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authorization 등 인증 헤더는 스킴만 남기고 토큰부 마스킹. */
|
||||||
|
private static String maskHeaderValue(String name, String value) {
|
||||||
|
if (!SECRET_KEYS.contains(name.toLowerCase())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
int space = value.indexOf(' ');
|
||||||
|
if (space > 0) {
|
||||||
|
return value.substring(0, space) + " " + partialMask(value.substring(space + 1).trim());
|
||||||
|
}
|
||||||
|
return partialMask(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 본문 마스킹
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private static String buildBodySummary(String contentType, boolean tokenRequest, String body) {
|
||||||
|
if (body == null || body.isEmpty()) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
// 토큰 발급: form 필드 단위 마스킹 (client_secret 전체 마스킹)
|
||||||
|
if (tokenRequest) {
|
||||||
|
return "\"" + maskFormBody(body) + "\"";
|
||||||
|
}
|
||||||
|
// 일반 요청: JSON 이면 값 단위 부분 마스킹, 그 외(비 JSON)는 길이 + 프리뷰만
|
||||||
|
if (contentType != null && contentType.toLowerCase().contains("json")) {
|
||||||
|
String maskedJson = tryMaskJson(body);
|
||||||
|
if (maskedJson != null) {
|
||||||
|
return maskedJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nonJsonSummary(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** k=v&k=v 형태 본문의 값 단위 마스킹. secret 키는 전체 마스킹. */
|
||||||
|
private static String maskFormBody(String body) {
|
||||||
|
StringBuilder sb = new StringBuilder(body.length());
|
||||||
|
String[] pairs = body.split("&");
|
||||||
|
for (int i = 0; i < pairs.length; i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
sb.append('&');
|
||||||
|
}
|
||||||
|
int eq = pairs[i].indexOf('=');
|
||||||
|
if (eq < 0) {
|
||||||
|
sb.append(partialMask(pairs[i]));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String key = pairs[i].substring(0, eq);
|
||||||
|
String value = pairs[i].substring(eq + 1);
|
||||||
|
sb.append(key).append('=');
|
||||||
|
sb.append(SECRET_KEYS.contains(key.toLowerCase()) ? "*****" : partialMask(value));
|
||||||
|
}
|
||||||
|
return sanitize(sb.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL 쿼리스트링 값 단위 마스킹 (경로는 그대로). */
|
||||||
|
private static String maskQueryValues(String url) {
|
||||||
|
int qs = url.indexOf('?');
|
||||||
|
if (qs < 0) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return url.substring(0, qs) + "?" + maskFormBody(url.substring(qs + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON 파싱 성공 시 값 단위 마스킹 문자열, 실패 시 null. */
|
||||||
|
private static String tryMaskJson(String body) {
|
||||||
|
try {
|
||||||
|
JsonNode masked = maskJsonNode(OBJECT_MAPPER.readTree(body));
|
||||||
|
String out = OBJECT_MAPPER.writeValueAsString(masked);
|
||||||
|
if (out.length() > JSON_LOG_MAX_LENGTH) {
|
||||||
|
out = out.substring(0, JSON_LOG_MAX_LENGTH) + "...(truncated)";
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON 트리의 leaf 값을 재귀적으로 마스킹. secret 키 필드는 전체 마스킹. */
|
||||||
|
private static JsonNode maskJsonNode(JsonNode node) {
|
||||||
|
if (node.isObject()) {
|
||||||
|
ObjectNode obj = (ObjectNode) node;
|
||||||
|
Iterator<String> names = obj.fieldNames();
|
||||||
|
Set<String> fieldNames = new HashSet<>();
|
||||||
|
while (names.hasNext()) {
|
||||||
|
fieldNames.add(names.next());
|
||||||
|
}
|
||||||
|
for (String field : fieldNames) {
|
||||||
|
if (SECRET_KEYS.contains(field.toLowerCase())) {
|
||||||
|
obj.set(field, TextNode.valueOf("*****"));
|
||||||
|
} else {
|
||||||
|
obj.set(field, maskJsonNode(obj.get(field)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
if (node.isArray()) {
|
||||||
|
ArrayNode arr = (ArrayNode) node;
|
||||||
|
for (int i = 0; i < arr.size(); i++) {
|
||||||
|
arr.set(i, maskJsonNode(arr.get(i)));
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
if (node.isNull() || node.isMissingNode()) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
return TextNode.valueOf(partialMask(node.asText()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 비 JSON 본문: 전체 길이(byte)와 앞 몇 글자만 노출. */
|
||||||
|
private static String nonJsonSummary(String body) {
|
||||||
|
int bytes = body.getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
String preview = body.length() <= NON_JSON_PREVIEW_LENGTH
|
||||||
|
? body : body.substring(0, NON_JSON_PREVIEW_LENGTH);
|
||||||
|
return "(non-json,bytes=" + bytes + ",preview=\"" + sanitize(preview) + "***\")";
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 공통 helper
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/** 앞 일부(최대 4자)만 남기고 마스킹. 2자 이하는 전체 마스킹. */
|
||||||
|
private static String partialMask(String value) {
|
||||||
|
if (value == null || value.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int len = value.length();
|
||||||
|
if (len <= 2) {
|
||||||
|
return stars(len);
|
||||||
|
}
|
||||||
|
int visible = Math.min(4, Math.max(1, len / 3));
|
||||||
|
return value.substring(0, visible) + "***";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stars(int count) {
|
||||||
|
char[] arr = new char[count];
|
||||||
|
Arrays.fill(arr, '*');
|
||||||
|
return new String(arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 제어문자·개행·따옴표를 치환해 한 줄 로그 형식을 보존. */
|
||||||
|
private static String sanitize(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder(value.length());
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
char c = value.charAt(i);
|
||||||
|
if (c == '"') {
|
||||||
|
sb.append('\'');
|
||||||
|
} else if (c == '\r' || c == '\n' || c == '\t') {
|
||||||
|
sb.append(' ');
|
||||||
|
} else if (c < 0x20) {
|
||||||
|
sb.append('?');
|
||||||
|
} else {
|
||||||
|
sb.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user