diff --git a/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterFilter.java b/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterFilter.java index f6df2ba..c5ae153 100644 --- a/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterFilter.java +++ b/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterFilter.java @@ -68,8 +68,16 @@ public class ApiTesterFilter implements Filter { ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class); String url = httpServletRequest.getHeader("original-url"); + // 감사 로그: 요청 1건당 REQ/RES 두 줄을 같은 auditId 로 남긴다 (전용 파일, 1년 보관) + String auditId = ApiTesterAuditLogger.newAuditId(); + long auditStart = System.currentTimeMillis(); + String auditType = "-"; + // original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지) if (url == null || url.trim().isEmpty()) { + ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, null, "-", false, null); + ApiTesterAuditLogger.logResult(auditId, HttpServletResponse.SC_BAD_REQUEST, "BAD_REQUEST", + System.currentTimeMillis() - auditStart); writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}"); return; } @@ -84,16 +92,15 @@ public class ApiTesterFilter implements Filter { boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH) || url.contains(gatewayProperty.tokenPath()); + // 본문은 한 번만 읽어 프록시 forward 와 감사 로그에 함께 사용 (GET 이면 빈 문자열) + String requestBody = readBody(httpServletRequest); + ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url, gatewayMode.name(), tokenRequest, requestBody); + if (tokenRequest) { - StringBuilder sb = new StringBuilder(); - BufferedReader reader = httpServletRequest.getReader(); - String line; - while ((line = reader.readLine()) != null) { - sb.append(line); - } - String body = sb.toString(); + String body = requestBody; if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) { + auditType = "TOKEN_MOCK"; // PortalMock: 고정 mock 토큰 반환 (기존 동작 유지) Map params = new HashMap<>(); String[] pairs = body.split("&"); @@ -106,7 +113,7 @@ public class ApiTesterFilter implements Filter { String scope = params.getOrDefault("scope", "default"); String token = "{\n" + - " \"access_token\": \"djbank_gw_sample_token\",\n" + + " \"access_token\": \"" + escapeJson(gatewayProperty.mockAccessToken()) + "\",\n" + " \"token_type\": \"bearer\",\n" + " \"expires_in\": 86400,\n" + " \"scope\": \""+scope +"\",\n" + @@ -117,6 +124,7 @@ public class ApiTesterFilter implements Filter { response.getWriter().println(token); } else { // GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만) + auditType = "TOKEN_GW"; APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class); Map headers = new HashMap<>(); headers.put("Content-Type", "application/x-www-form-urlencoded"); @@ -133,6 +141,7 @@ public class ApiTesterFilter implements Filter { // URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지) if (apiSpecInfoDto == null) { + auditType = "SPEC_NOT_FOUND"; writeJson(response, HttpServletResponse.SC_NOT_FOUND, "{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}"); return; @@ -142,6 +151,7 @@ public class ApiTesterFilter implements Filter { // sample(기본): 저장된 샘플 응답 반환 (실호출 없음) if (responseType == null || responseType.equalsIgnoreCase("sample")) { + auditType = "SAMPLE"; response.setContentType("application/json"); response.getWriter().println(apiSpecInfoDto.getSampleResponse()); return; @@ -151,6 +161,7 @@ public class ApiTesterFilter implements Filter { // - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path) // - mock : ApiSpecInfo.mockUrl (기존 동작) boolean gw = "gw".equalsIgnoreCase(responseType); + auditType = gw ? "GW" : "MOCK"; Map headers = new HashMap<>(); Enumeration headerNames = httpServletRequest.getHeaderNames(); @@ -160,6 +171,10 @@ public class ApiTesterFilter implements Filter { } headers.remove("original-url"); headers.remove("original-api-id"); + // readBody()가 개행을 제거해 원본 Content-Length와 실제 전송 바이트가 달라질 수 있고, + // WebLogic HTTP 클라이언트는 이 불일치를 IOException으로 처리하므로 length 계열 헤더는 + // 전달하지 않는다(HttpURLConnection이 실제 바이트 수로 재설정). + headers.keySet().removeIf(k -> "content-length".equalsIgnoreCase(k) || "transfer-encoding".equalsIgnoreCase(k)); String targetUri; Map paramMap; @@ -179,7 +194,7 @@ public class ApiTesterFilter implements Filter { APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class); String responseStr; if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) { - responseStr = apiSender.requestPost(targetUri, headers, paramMap, readBody(httpServletRequest)); + responseStr = apiSender.requestPost(targetUri, headers, paramMap, requestBody); } else { responseStr = apiSender.requestGet(targetUri, headers, paramMap); } @@ -202,6 +217,9 @@ public class ApiTesterFilter implements Filter { logger.error("테스트베드 프록시 처리 오류", e); writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}"); + } finally { + ApiTesterAuditLogger.logResult(auditId, ((HttpServletResponse) response).getStatus(), auditType, + System.currentTimeMillis() - auditStart); } } diff --git a/src/main/java/com/eactive/apim/portal/djb/testbed/config/DjbTestbedGatewayProperty.java b/src/main/java/com/eactive/apim/portal/djb/testbed/config/DjbTestbedGatewayProperty.java index 836ab16..6aa9497 100644 --- a/src/main/java/com/eactive/apim/portal/djb/testbed/config/DjbTestbedGatewayProperty.java +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/config/DjbTestbedGatewayProperty.java @@ -32,6 +32,7 @@ public class DjbTestbedGatewayProperty { public static final String KEY_TIMEOUT_SEC = "djb.gateway.timeout"; public static final String KEY_USE_PROXY = "djb.gateway.use-proxy"; public static final String KEY_TOKEN_USE_PROXY = "djb.gateway.token-use-proxy"; + public static final String KEY_MOCK_ACCESS_TOKEN = "djb.gateway.mock-access-token"; public static final String DEFAULT_BASE_URL = "PortalMock"; public static final String DEFAULT_TIMEOUT_SEC = "10"; @@ -47,6 +48,9 @@ public class DjbTestbedGatewayProperty { /** PortalMock 모드에서 사용하는 포털 기존 mock 토큰 경로. */ public static final String PORTAL_MOCK_TOKEN_PATH = "/api/v1/oauth/token"; + /** PortalMock 모드 토큰 응답의 access_token 기본값. */ + public static final String DEFAULT_MOCK_ACCESS_TOKEN = "djbank_gw_sample_token"; + public String baseUrl() { return resolve(KEY_BASE_URL, DEFAULT_BASE_URL, "GW Base URL. 문자열 \"PortalMock\" 이면 ApiTesterFilter 가 mock 토큰 반환"); @@ -86,6 +90,12 @@ public class DjbTestbedGatewayProperty { "테스트베드 토큰 발급 시 서버 프록시(/api/call-api) 사용 여부(true/false). false 이면 브라우저에서 직접 호출."), true); } + /** PortalMock 모드 토큰 응답에 넣을 access_token 값. */ + public String mockAccessToken() { + return resolve(KEY_MOCK_ACCESS_TOKEN, DEFAULT_MOCK_ACCESS_TOKEN, + "PortalMock 모드 토큰 발급 응답의 access_token 값"); + } + /** * 프로퍼티 값을 boolean 으로 해석. 레거시 {@code Y/N} 값도 자동 변환한다. * {@code true}/{@code Y} → true, {@code false}/{@code N} → false, 그 외/null → {@code def}. diff --git a/src/main/java/com/eactive/apim/portal/djb/testbed/controller/DjbTestbedSpecController.java b/src/main/java/com/eactive/apim/portal/djb/testbed/controller/DjbTestbedSpecController.java index 0bc4786..f515c98 100644 --- a/src/main/java/com/eactive/apim/portal/djb/testbed/controller/DjbTestbedSpecController.java +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/controller/DjbTestbedSpecController.java @@ -25,8 +25,11 @@ import org.springframework.web.bind.annotation.RestController; /** * testbed spec(swagger.json/yaml)에 AUTHTYPE 기반 securityScheme 를 주입하고, - * 서버 sentinel({@link DjbTestbedSpecServerRewriter#SERVER_SENTINEL})을 API SPEC 설정(responseType)에 - * 따른 실주소로 치환해 반환한다. + * 서버 sentinel({@link DjbTestbedSpecServerRewriter#SERVER_SENTINEL})을 실주소로 치환해 반환한다. + *
    + *
  • {@code swagger.json}/{@code swagger.yaml} : 외부 공개/다운로드용 — 항상 GW 주소로 치환
  • + *
  • {@code swagger-ui.json} : Swagger UI 전용 — API SPEC 설정(responseType: sample/mock/gw)에 따라 치환
  • + *
* {@code default-token-api-spec} 은 클래스패스 기본 spec 을 그대로 반환(auth enrich 대상 외). */ @RestController @@ -45,22 +48,34 @@ public class DjbTestbedSpecController { @GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException { - String json = buildSpecJson(id, request); + String json = buildSpecJson(id, request, true); return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json); } @GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml") public ResponseEntity swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException { - String json = buildSpecJson(id, request); + String json = buildSpecJson(id, request, true); return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json)); } - /** default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null. */ - private String buildSpecJson(String id, HttpServletRequest request) throws IOException { + /** Swagger UI 전용 spec — 서버 주소를 responseType(sample/mock/gw) 설정에 따라 치환. */ + @GetMapping(value = "/{id}/swagger-ui.json", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity swaggerForUi(@PathVariable String id, HttpServletRequest request) throws IOException { + String json = buildSpecJson(id, request, false); + return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json); + } + + /** + * default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null. + * @param alwaysGateway true 면 항상 GW 주소 치환(다운로드용), false 면 responseType 설정 기반(UI용) + */ + private String buildSpecJson(String id, HttpServletRequest request, boolean alwaysGateway) throws IOException { if (DEFAULT_TOKEN_API_ID.equals(id)) { Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH); String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8); - return serverRewriter.rewriteServer(content, null, request); + return alwaysGateway + ? serverRewriter.rewriteServerToGateway(content, request) + : serverRewriter.rewriteServer(content, null, request); } Optional spec = apiSpecInfoService.findById(id); @@ -70,6 +85,8 @@ public class DjbTestbedSpecController { DjbAuthType authType = authService.resolveAuthType(id); String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType); - return serverRewriter.rewriteServer(enriched, spec.get(), request); + return alwaysGateway + ? serverRewriter.rewriteServerToGateway(enriched, request) + : serverRewriter.rewriteServer(enriched, spec.get(), request); } } diff --git a/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedSpecServerRewriter.java b/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedSpecServerRewriter.java index a96a3a4..aaaf7b7 100644 --- a/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedSpecServerRewriter.java +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedSpecServerRewriter.java @@ -50,6 +50,22 @@ public class DjbTestbedSpecServerRewriter { return specJson.replace(SERVER_SENTINEL, base); } + /** + * sentinel → GW 주소({@link DjbTestbedGatewayProperty#resolveApiBaseUrl}) 치환한 spec JSON 반환. + * responseType 을 무시하고 항상 GW 기준으로 치환한다 — 외부 공개/다운로드용 spec(swagger.json/yaml) + * 단일 기준. (Swagger UI 표시용은 {@link #rewriteServer} 의 responseType 분기를 그대로 사용.) + */ + public String rewriteServerToGateway(String specJson, HttpServletRequest request) { + if (specJson == null || !specJson.contains(SERVER_SENTINEL)) { + return specJson; + } + String base = stripTrailingSlash(gatewayProperty.resolveApiBaseUrl(originOf(request))); + if (!StringUtils.hasText(base)) { + return specJson; // 실주소 미확정 시 sentinel 유지 + } + return specJson.replace(SERVER_SENTINEL, base); + } + /** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */ public String toYaml(String specJson) { try { diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 5a2899a..848e509 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -56,6 +56,19 @@ + + + ${LOG_PATH}/apitester-audit.log + + ${LOG_PATH}/backup/apitester-audit.%d{yyyy-MM-dd}.%i.log + 200MB + 365 + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %msg%n + + + ${CONSOLE_EFFECTIVE_LEVEL} @@ -68,6 +81,10 @@ + + + + diff --git a/src/main/resources/templates/views/apps/apis/mainApiDetail.html b/src/main/resources/templates/views/apps/apis/mainApiDetail.html index 16fcda0..7620d4a 100644 --- a/src/main/resources/templates/views/apps/apis/mainApiDetail.html +++ b/src/main/resources/templates/views/apps/apis/mainApiDetail.html @@ -305,7 +305,9 @@ return; } - const swaggerUrl = `/djb/testbed/apis/${apiId}/swagger.json`; + // UI 전용 spec: 서버 주소가 responseType(sample/mock/gw) 설정에 따라 치환됨. + // (swagger.json 은 외부 다운로드용으로 항상 GW 주소 고정) + const swaggerUrl = `/djb/testbed/apis/${apiId}/swagger-ui.json`; // Load Swagger UI scripts dynamically const loadScript = (src) => {