From 5d70962521d7267cda07b0bf6dab2d25520eba84 Mon Sep 17 00:00:00 2001 From: Rinjae Date: Thu, 2 Jul 2026 18:16:57 +0900 Subject: [PATCH] =?UTF-8?q?-=202FA=20=EC=88=98=EC=8B=A0=EC=9E=90=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=95=EC=A0=81=20=ED=95=B4=EC=8B=9C=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?/=EC=A4=91=EB=B3=B5=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=20-=20Swagger=20UI=20i18n=20=ED=95=9C=EA=B8=80=20=ED=8C=A8?= =?UTF-8?q?=EC=B9=98=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80=20-=20Swagg?= =?UTF-8?q?er=20UI=20=EC=9D=91=EB=8B=B5/=EC=8A=A4=EB=8B=88=ED=8E=AB=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20=EC=BB=A4=EC=8A=A4=ED=85=80=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BD=94=EB=93=9C=20=EC=83=9D=EC=84=B1=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../6_ptl_two_factor_auth_recipient_key.sql | 23 ++ .../advice/DjbTestbedExceptionHandler.java | 37 +++ .../config/DjbTestbedGatewayProperty.java | 90 ++++++ .../controller/DjbTestbedAuthController.java | 51 ++++ .../controller/DjbTestbedSpecController.java | 58 ++++ .../testbed/dto/DjbCredentialOptionDto.java | 21 ++ .../testbed/dto/DjbCredentialSecretDto.java | 20 ++ .../djb/testbed/dto/DjbTestbedContextDto.java | 36 +++ .../portal/djb/testbed/enums/DjbAuthType.java | 35 +++ .../djb/testbed/enums/DjbGatewayMode.java | 14 + .../DjbUnsupportedAuthTypeException.java | 20 ++ .../service/DjbSwaggerSpecEnricher.java | 88 ++++++ .../service/DjbTestbedAuthService.java | 133 +++++++++ .../plugins/swaggerUI/djb-swagger-i18n.js | 161 ++++++++++ .../plugins/swaggerUI/djb-swagger-response.js | 187 ++++++++++++ .../swaggerUI/djb-swagger-snippet-panel.js | 251 ++++++++++++++++ .../plugins/swaggerUI/djb-swagger-snippets.js | 223 ++++++++++++++ .../plugins/swaggerUI/djb-swagger-testbed.css | 277 ++++++++++++++++++ .../service/DjbSwaggerSpecEnricherTest.java | 77 +++++ .../service/DjbTestbedAuthServiceTest.java | 196 +++++++++++++ 20 files changed, 1998 insertions(+) create mode 100644 script/portal/oracle/6_ptl_two_factor_auth_recipient_key.sql create mode 100644 src/main/java/com/eactive/apim/portal/djb/testbed/advice/DjbTestbedExceptionHandler.java create mode 100644 src/main/java/com/eactive/apim/portal/djb/testbed/config/DjbTestbedGatewayProperty.java create mode 100644 src/main/java/com/eactive/apim/portal/djb/testbed/controller/DjbTestbedAuthController.java create mode 100644 src/main/java/com/eactive/apim/portal/djb/testbed/controller/DjbTestbedSpecController.java create mode 100644 src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbCredentialOptionDto.java create mode 100644 src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbCredentialSecretDto.java create mode 100644 src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbTestbedContextDto.java create mode 100644 src/main/java/com/eactive/apim/portal/djb/testbed/enums/DjbAuthType.java create mode 100644 src/main/java/com/eactive/apim/portal/djb/testbed/enums/DjbGatewayMode.java create mode 100644 src/main/java/com/eactive/apim/portal/djb/testbed/exception/DjbUnsupportedAuthTypeException.java create mode 100644 src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbSwaggerSpecEnricher.java create mode 100644 src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedAuthService.java create mode 100644 src/main/resources/static/plugins/swaggerUI/djb-swagger-i18n.js create mode 100644 src/main/resources/static/plugins/swaggerUI/djb-swagger-response.js create mode 100644 src/main/resources/static/plugins/swaggerUI/djb-swagger-snippet-panel.js create mode 100644 src/main/resources/static/plugins/swaggerUI/djb-swagger-snippets.js create mode 100644 src/main/resources/static/plugins/swaggerUI/djb-swagger-testbed.css create mode 100644 src/test/java/com/eactive/apim/portal/djb/testbed/service/DjbSwaggerSpecEnricherTest.java create mode 100644 src/test/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedAuthServiceTest.java diff --git a/script/portal/oracle/6_ptl_two_factor_auth_recipient_key.sql b/script/portal/oracle/6_ptl_two_factor_auth_recipient_key.sql new file mode 100644 index 0000000..96139b5 --- /dev/null +++ b/script/portal/oracle/6_ptl_two_factor_auth_recipient_key.sql @@ -0,0 +1,23 @@ +-- ============================================================================= +-- 2FA 인증코드 조회용 결정적 해시 키 컬럼 추가 +-- 대상: EMS 스키마 소유자(EMSAPP 등)로 접속하여 실행 +-- 엔티티: com.eactive.apim.portal.portaluser.entity.TwoFactorAuth +-- +-- 배경: RECIPIENT(이메일/휴대폰) 컬럼에 PersonalDataEncryptConverter 암호화가 +-- 적용되면서, 값 동등 조회(WHERE RECIPIENT = ?)와 중복정리가 깨져 +-- 인증코드 검증이 "일치하지 않습니다"로 실패함. +-- 해결: RECIPIENT 는 암호화 보관(감사/표시용)을 유지하고, 평문의 결정적 해시 +-- (HMAC-SHA256, 소문자 hex 64자)를 RECIPIENT_KEY 에 저장하여 조회/정리에 사용. +-- +-- ※ hibernate.hbm2ddl.auto=validate 이므로, 신규 엔티티 배포(재기동) "전"에 +-- 반드시 이 스크립트를 먼저 실행해야 기동 검증을 통과함. +-- ============================================================================= + +ALTER TABLE PTL_TWO_FACTOR_AUTH ADD (RECIPIENT_KEY VARCHAR2(64)); + +-- 인증코드 조회/중복정리는 RECIPIENT_KEY 기준 +CREATE INDEX IX_PTL_TWO_FACTOR_AUTH_RKEY ON PTL_TWO_FACTOR_AUTH (RECIPIENT_KEY); + +COMMENT ON COLUMN PTL_TWO_FACTOR_AUTH.RECIPIENT_KEY IS '수신자 결정적 해시 조회키 (HMAC-SHA256 hex). RECIPIENT 암호화로 깨지는 동등조회/중복정리용'; + +COMMIT; diff --git a/src/main/java/com/eactive/apim/portal/djb/testbed/advice/DjbTestbedExceptionHandler.java b/src/main/java/com/eactive/apim/portal/djb/testbed/advice/DjbTestbedExceptionHandler.java new file mode 100644 index 0000000..de4415a --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/advice/DjbTestbedExceptionHandler.java @@ -0,0 +1,37 @@ +package com.eactive.apim.portal.djb.testbed.advice; + +import com.eactive.apim.portal.djb.testbed.controller.DjbTestbedAuthController; +import com.eactive.apim.portal.djb.testbed.controller.DjbTestbedSpecController; +import com.eactive.apim.portal.djb.testbed.exception.DjbUnsupportedAuthTypeException; +import java.util.HashMap; +import java.util.Map; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * 테스트베드 두 컨트롤러 한정 예외 → JSON 변환. + * (qna 로컬 핸들러와 동일한 {@code {code, message}} 포맷.) + */ +@RestControllerAdvice(assignableTypes = {DjbTestbedAuthController.class, DjbTestbedSpecController.class}) +public class DjbTestbedExceptionHandler { + + @ExceptionHandler(DjbUnsupportedAuthTypeException.class) + public ResponseEntity> handleUnsupportedAuthType(DjbUnsupportedAuthTypeException ex) { + Map body = new HashMap<>(); + body.put("code", "UNSUPPORTED_AUTHTYPE"); + body.put("message", ex.getMessage()); + body.put("authtype", ex.getAuthtype()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDenied(AccessDeniedException ex) { + Map body = new HashMap<>(); + body.put("code", "FORBIDDEN"); + body.put("message", ex.getMessage()); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body); + } +} 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 new file mode 100644 index 0000000..132332b --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/config/DjbTestbedGatewayProperty.java @@ -0,0 +1,90 @@ +package com.eactive.apim.portal.djb.testbed.config; + +import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode; +import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** + * 테스트베드 게이트웨이 설정을 {@code PTL_PROPERTY}(그룹 {@code Portal}, key prefix {@code djb.gateway.}) + * 에서 읽는 얇은 접근자. 별도 property-injection 인프라 대신 기존 + * {@link PortalPropertyService#getOrCreateProperty} 를 직접 사용한다(사용자 지시). + * + *

{@code getOrCreateProperty} 는 DB row 미존재 시 default 로 INSERT(그룹 존재 시)하지만 + * 공백 값 보정은 하지 않으므로, 여기 {@link #resolve} 에서 {@code isBlank → default} 폴백을 둔다. + * + *

비-Spring 컴포넌트인 {@code ApiTesterFilter}(@WebFilter)는 + * {@code ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class)} 로 접근한다. + */ +@Component +@RequiredArgsConstructor +public class DjbTestbedGatewayProperty { + + private final PortalPropertyService portalPropertyService; + + private static final String GROUP = "Portal"; + + public static final String KEY_BASE_URL = "djb.gateway.base-url"; + public static final String KEY_TOKEN_PATH = "djb.gateway.token-path"; + public static final String KEY_API_KEY_HEADER = "djb.gateway.api-key-header"; + public static final String KEY_OAUTH_HEADER = "djb.gateway.oauth-token-header"; + + public static final String DEFAULT_BASE_URL = "PortalMock"; + /** GW 모드 실제 토큰 엔드포인트 경로 (DJERP_4000 OAuth 발급가이드 v0.2). */ + public static final String DEFAULT_TOKEN_PATH = "/dj/oauth/token"; + public static final String DEFAULT_API_KEY_HEADER = "X-DJB-API-Key"; + /** 게이트웨이 API 호출 시 액세스 토큰 헤더명 (가이드 v0.2: Authorization → X-AUTH-TOKEN). */ + public static final String DEFAULT_OAUTH_HEADER = "X-AUTH-TOKEN"; + + /** PortalMock 모드에서 사용하는 포털 기존 mock 토큰 경로. */ + public static final String PORTAL_MOCK_TOKEN_PATH = "/api/v1/oauth/token"; + + public String baseUrl() { + return resolve(KEY_BASE_URL, DEFAULT_BASE_URL, + "GW Base URL. 문자열 \"PortalMock\" 이면 ApiTesterFilter 가 mock 토큰 반환"); + } + + public String tokenPath() { + return resolve(KEY_TOKEN_PATH, DEFAULT_TOKEN_PATH, + "OAuth 토큰 발급 경로 (GW 모드에서만 사용)"); + } + + public String apiKeyHeader() { + return resolve(KEY_API_KEY_HEADER, DEFAULT_API_KEY_HEADER, + "API Key 전달 헤더명"); + } + + public String oauthHeader() { + return resolve(KEY_OAUTH_HEADER, DEFAULT_OAUTH_HEADER, + "OAuth 액세스 토큰 전달 헤더명 (Bearer 토큰)"); + } + + public DjbGatewayMode resolveGatewayMode() { + return DEFAULT_BASE_URL.equalsIgnoreCase(baseUrl()) ? DjbGatewayMode.PORTAL_MOCK : DjbGatewayMode.GATEWAY; + } + + /** + * OAuth 토큰 발급 URL. + * PortalMock → {@code portalOrigin + /api/v1/oauth/token} (기존 mock 필터가 가로챔), + * GATEWAY → {@code baseUrl + tokenPath}. + */ + public String resolveTokenUrl(String portalOrigin) { + if (resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) { + return stripTrailingSlash(portalOrigin) + PORTAL_MOCK_TOKEN_PATH; + } + return stripTrailingSlash(baseUrl()) + tokenPath(); + } + + private String resolve(String key, String defaultValue, String description) { + String value = portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description); + return StringUtils.hasText(value) ? value.trim() : defaultValue; + } + + private static String stripTrailingSlash(String s) { + if (s == null) { + return ""; + } + return s.endsWith("/") ? s.substring(0, s.length() - 1) : s; + } +} diff --git a/src/main/java/com/eactive/apim/portal/djb/testbed/controller/DjbTestbedAuthController.java b/src/main/java/com/eactive/apim/portal/djb/testbed/controller/DjbTestbedAuthController.java new file mode 100644 index 0000000..3d9434f --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/controller/DjbTestbedAuthController.java @@ -0,0 +1,51 @@ +package com.eactive.apim.portal.djb.testbed.controller; + +import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialSecretDto; +import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto; +import com.eactive.apim.portal.djb.testbed.service.DjbTestbedAuthService; +import javax.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 테스트베드 인증 컨텍스트/시크릿 REST. + * 컨트롤러 진입은 인증 없이 허용하되(비대상자도 context 를 받아야 함), + * 실제 인증/역할/소유권 검증은 {@link DjbTestbedAuthService} 가 수행한다. + */ +@RestController +@RequestMapping("/djb/testbed/auth") +@RequiredArgsConstructor +public class DjbTestbedAuthController { + + private final DjbTestbedAuthService authService; + + @GetMapping("/context") + public ResponseEntity context(HttpServletRequest request) { + return ResponseEntity.ok(authService.buildContext(resolveOrigin(request))); + } + + @GetMapping("/credentials/{clientId}/secret") + public ResponseEntity credentialSecret(@PathVariable String clientId, + @RequestParam String apiId) { + DjbCredentialSecretDto secret = authService.getCredentialSecret(clientId, apiId); + return ResponseEntity.ok() + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .header(HttpHeaders.PRAGMA, "no-cache") + .body(secret); + } + + /** scheme://host[:port] (기본 포트는 생략). PortalMock 토큰 URL 구성에 사용. */ + private String resolveOrigin(HttpServletRequest request) { + String scheme = request.getScheme(); + String host = request.getServerName(); + int port = request.getServerPort(); + boolean defaultPort = ("http".equals(scheme) && port == 80) || ("https".equals(scheme) && port == 443); + return scheme + "://" + host + (defaultPort ? "" : ":" + port); + } +} 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 new file mode 100644 index 0000000..824d676 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/controller/DjbTestbedSpecController.java @@ -0,0 +1,58 @@ +package com.eactive.apim.portal.djb.testbed.controller; + +import com.eactive.apim.portal.apispec.entity.ApiSpecInfo; +import com.eactive.apim.portal.apispec.service.ApiSpecInfoService; +import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType; +import com.eactive.apim.portal.djb.testbed.service.DjbSwaggerSpecEnricher; +import com.eactive.apim.portal.djb.testbed.service.DjbTestbedAuthService; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * testbed spec(swagger.json)에 AUTHTYPE 기반 securityScheme 를 주입해 반환. + * {@code default-token-api-spec} 은 클래스패스 기본 spec 을 그대로 반환(enrich 대상 외). + */ +@RestController +@RequestMapping("/djb/testbed/apis") +@RequiredArgsConstructor +@Slf4j +public class DjbTestbedSpecController { + + private final ApiSpecInfoService apiSpecInfoService; + private final DjbTestbedAuthService authService; + private final DjbSwaggerSpecEnricher enricher; + + private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec"; + private static final String DEFAULT_SPEC_PATH = "swagger/default-token-api-spec.json"; + + @GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity swaggerWithAuth(@PathVariable String id) 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 ResponseEntity.ok(content); + } + + Optional spec = apiSpecInfoService.findById(id); + if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) { + return ResponseEntity.notFound().build(); + } + + DjbAuthType authType = authService.resolveAuthType(id); + String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType); + return ResponseEntity.ok(enriched); + } +} diff --git a/src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbCredentialOptionDto.java b/src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbCredentialOptionDto.java new file mode 100644 index 0000000..467628b --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbCredentialOptionDto.java @@ -0,0 +1,21 @@ +package com.eactive.apim.portal.djb.testbed.dto; + +import lombok.Builder; +import lombok.Data; + +/** + * 테스트베드 앱 셀렉트에 노출할 앱(PTL_CREDENTIAL) 1건. 시크릿은 포함하지 않는다. + */ +@Data +@Builder +public class DjbCredentialOptionDto { + + /** PTL_CREDENTIAL.clientid */ + private String clientId; + + /** 사용자에게 보여줄 앱명(PTL_CREDENTIAL.clientname) */ + private String clientName; + + /** "0": 차단, "1": 정상 */ + private String appStatus; +} diff --git a/src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbCredentialSecretDto.java b/src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbCredentialSecretDto.java new file mode 100644 index 0000000..7f2f39e --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbCredentialSecretDto.java @@ -0,0 +1,20 @@ +package com.eactive.apim.portal.djb.testbed.dto; + +import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType; +import lombok.Builder; +import lombok.Data; + +/** + * 선택한 앱의 시크릿 단건 응답(캐시 금지 · 로그 마스킹 대상). + * OAUTH 시 {@code clientSecret} 은 client_secret, API_KEY 시 API Key 값이다. + */ +@Data +@Builder +public class DjbCredentialSecretDto { + + private String clientId; + + private String clientSecret; + + private DjbAuthType authType; +} diff --git a/src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbTestbedContextDto.java b/src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbTestbedContextDto.java new file mode 100644 index 0000000..db3c3c1 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbTestbedContextDto.java @@ -0,0 +1,36 @@ +package com.eactive.apim.portal.djb.testbed.dto; + +import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode; +import java.util.List; +import lombok.Builder; +import lombok.Data; + +/** + * {@code GET /djb/testbed/auth/context} 응답. 시크릿은 포함하지 않는다. + */ +@Data +@Builder +public class DjbTestbedContextDto { + + /** 인증 정보 제공 대상자 여부 */ + private boolean eligible; + + /** 비대상 사유 코드: ANONYMOUS / INDIVIDUAL_USER / NO_CREDENTIAL (대상자면 null) */ + private String reason; + + /** 사용자가 선택할 수 있는 앱 목록 */ + private List credentials; + + /** 게이트웨이 정보(시크릿 제외) */ + private GatewayInfo gateway; + + @Data + @Builder + public static class GatewayInfo { + private DjbGatewayMode mode; + private String baseUrl; + private String tokenUrl; + private String apiKeyHeader; + private String oauthTokenHeader; + } +} diff --git a/src/main/java/com/eactive/apim/portal/djb/testbed/enums/DjbAuthType.java b/src/main/java/com/eactive/apim/portal/djb/testbed/enums/DjbAuthType.java new file mode 100644 index 0000000..bc841cd --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/enums/DjbAuthType.java @@ -0,0 +1,35 @@ +package com.eactive.apim.portal.djb.testbed.enums; + +import com.eactive.apim.portal.djb.testbed.exception.DjbUnsupportedAuthTypeException; + +/** + * 테스트베드가 지원하는 인증 체계. {@code TSEAIHE01.AUTHTYPE}(소문자 저장) 값을 매핑한다. + *

    + *
  • {@code oauth}/{@code oauth2}/{@code ca} → {@link #OAUTH}
  • + *
  • {@code api_key}/{@code apikey} → {@link #API_KEY}
  • + *
  • 그 외 → {@link DjbUnsupportedAuthTypeException}
  • + *
+ * ({@code ca} 는 게이트웨이 레거시 매퍼 {@code ObpApiService.mapAuthType} 이 OAUTH2 로 취급하는 값.) + */ +public enum DjbAuthType { + OAUTH, + API_KEY; + + public static DjbAuthType fromAuthtype(String authtype) { + if (authtype == null) { + throw new DjbUnsupportedAuthTypeException("null"); + } + String normalized = authtype.trim().toLowerCase(); + switch (normalized) { + case "oauth": + case "oauth2": + case "ca": + return OAUTH; + case "api_key": + case "apikey": + return API_KEY; + default: + throw new DjbUnsupportedAuthTypeException(authtype); + } + } +} diff --git a/src/main/java/com/eactive/apim/portal/djb/testbed/enums/DjbGatewayMode.java b/src/main/java/com/eactive/apim/portal/djb/testbed/enums/DjbGatewayMode.java new file mode 100644 index 0000000..6da47b5 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/enums/DjbGatewayMode.java @@ -0,0 +1,14 @@ +package com.eactive.apim.portal.djb.testbed.enums; + +/** + * 테스트베드 호출이 향하는 대상 모드. + *
    + *
  • {@code PORTAL_MOCK} — {@code djb.gateway.base-url} 이 문자열 "PortalMock" 인 경우. + * {@code ApiTesterFilter} 가 mock 토큰/샘플 응답을 반환한다.
  • + *
  • {@code GATEWAY} — 그 외(실제 게이트웨이 URL). 토큰 발급 요청을 실 게이트웨이로 forward.
  • + *
+ */ +public enum DjbGatewayMode { + PORTAL_MOCK, + GATEWAY +} diff --git a/src/main/java/com/eactive/apim/portal/djb/testbed/exception/DjbUnsupportedAuthTypeException.java b/src/main/java/com/eactive/apim/portal/djb/testbed/exception/DjbUnsupportedAuthTypeException.java new file mode 100644 index 0000000..24bf1de --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/exception/DjbUnsupportedAuthTypeException.java @@ -0,0 +1,20 @@ +package com.eactive.apim.portal.djb.testbed.exception; + +/** + * TSEAIHE01.AUTHTYPE 값이 테스트베드가 지원하는 인증 체계(oauth/api_key)로 + * 매핑되지 않을 때 발생. {@link com.eactive.apim.portal.djb.testbed.advice.DjbTestbedExceptionHandler} + * 에서 400 응답으로 변환된다. + */ +public class DjbUnsupportedAuthTypeException extends RuntimeException { + + private final String authtype; + + public DjbUnsupportedAuthTypeException(String authtype) { + super("지원하지 않는 인증 체계: " + authtype); + this.authtype = authtype; + } + + public String getAuthtype() { + return authtype; + } +} diff --git a/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbSwaggerSpecEnricher.java b/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbSwaggerSpecEnricher.java new file mode 100644 index 0000000..c29ff17 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbSwaggerSpecEnricher.java @@ -0,0 +1,88 @@ +package com.eactive.apim.portal.djb.testbed.service; + +import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty; +import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType; +import com.fasterxml.jackson.core.JsonProcessingException; +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 lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * testbed spec(JSON)에 인증 체계를 주입한다. + * + *

현재 데이터는 OpenAPI 3.0(auto-gen {@code "openapi":"3.0.0"})이므로 + * {@code components.securitySchemes} + 글로벌 {@code security} 에 주입한다. + * (원본이 Swagger 2.0 이면 {@code securityDefinitions} 로 폴백.) + * + *

게이트웨이가 OAuth 액세스 토큰을 표준 {@code Authorization} 이 아니라 커스텀 헤더 + * {@code X-AUTH-TOKEN: Bearer ...} 로 받으므로(DJERP_4000 v0.2), OAuth 도 API_KEY 와 동일하게 + * apiKey-in-header 스킴으로 모델링한다(헤더명만 상이). 토큰 발급은 템플릿 JS가 + * {@code /api/call-api} 경유로 수행해 값을 {@code "Bearer "} 으로 authorize 한다. + * + *

원본의 {@code paths}, {@code x-*} 확장, 기타 {@code components} 는 트리 조작으로 보존한다 + * (문자열 치환 금지). + */ +@Service +@RequiredArgsConstructor +public class DjbSwaggerSpecEnricher { + + private final ObjectMapper objectMapper; + private final DjbTestbedGatewayProperty gatewayProperty; + + public static final String OAUTH_SCHEME = "djbOAuth"; + public static final String API_KEY_SCHEME = "djbApiKey"; + + public String enrich(String specJson, DjbAuthType authType) throws JsonProcessingException { + JsonNode parsed = objectMapper.readTree(specJson); + if (!parsed.isObject()) { + // 예상 밖 형태면 원본 유지(멱등) + return specJson; + } + ObjectNode root = (ObjectNode) parsed; + + String schemeName = (authType == DjbAuthType.OAUTH) ? OAUTH_SCHEME : API_KEY_SCHEME; + ObjectNode scheme = buildApiKeyScheme(authType); + + boolean swagger2 = root.has("swagger") && !root.has("openapi"); + if (swagger2) { + getOrCreateObject(root, "securityDefinitions").set(schemeName, scheme); + } else { + ObjectNode components = getOrCreateObject(root, "components"); + getOrCreateObject(components, "securitySchemes").set(schemeName, scheme); + } + + // 글로벌 security 요구사항 (apiKey 스킴은 빈 스코프 배열) + ArrayNode security = objectMapper.createArrayNode(); + ObjectNode requirement = objectMapper.createObjectNode(); + requirement.set(schemeName, objectMapper.createArrayNode()); + security.add(requirement); + root.set("security", security); + + return objectMapper.writeValueAsString(root); + } + + /** {@code { "type":"apiKey", "in":"header", "name":"

" }} — 2.0/3.0 동일 형태. */ + private ObjectNode buildApiKeyScheme(DjbAuthType authType) { + String headerName = (authType == DjbAuthType.OAUTH) + ? gatewayProperty.oauthHeader() + : gatewayProperty.apiKeyHeader(); + ObjectNode scheme = objectMapper.createObjectNode(); + scheme.put("type", "apiKey"); + scheme.put("in", "header"); + scheme.put("name", headerName); + return scheme; + } + + private ObjectNode getOrCreateObject(ObjectNode parent, String field) { + JsonNode node = parent.get(field); + if (node != null && node.isObject()) { + return (ObjectNode) node; + } + ObjectNode created = objectMapper.createObjectNode(); + parent.set(field, created); + return created; + } +} diff --git a/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedAuthService.java b/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedAuthService.java new file mode 100644 index 0000000..18a30d4 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedAuthService.java @@ -0,0 +1,133 @@ +package com.eactive.apim.portal.djb.testbed.service; + +import com.eactive.apim.gateway.data.eaimessage.EAIMessageRepository; +import com.eactive.apim.portal.app.entity.Credential; +import com.eactive.apim.portal.app.repository.CredentialRepository; +import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; +import com.eactive.apim.portal.common.util.SecurityUtil; +import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty; +import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialOptionDto; +import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialSecretDto; +import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto; +import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto.GatewayInfo; +import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType; +import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode; +import com.eactive.eai.data.entity.onl.message.EAIMessageEntity; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; + +/** + * 테스트베드 인증 컨텍스트 구성 · 앱 시크릿 조회 · authtype 매핑. + * 인증/역할/소유권 검증은 이 서비스에서 수행한다({@code SecurityFilterChain} 은 URL 인가 블록이 없음). + */ +@Service +@RequiredArgsConstructor +public class DjbTestbedAuthService { + + private final CredentialRepository credentialRepository; + private final EAIMessageRepository eaiMessageRepository; + private final DjbTestbedGatewayProperty gatewayProperty; + + private static final String APP_STATUS_ACTIVE = "1"; + + /** + * 비대상 사유: {@code ANONYMOUS}(비로그인) / {@code INDIVIDUAL_USER}(ROLE_USER) / + * {@code NO_CREDENTIAL}(appstatus='1' 앱 0건). 하나라도 해당하면 {@code eligible=false}. + */ + public DjbTestbedContextDto buildContext(String portalOrigin) { + GatewayInfo gateway = buildGatewayInfo(portalOrigin); + + if (!SecurityUtil.isAuthenticated()) { + return notEligible("ANONYMOUS", gateway); + } + PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser(); + if (user.getRoleCode() == RoleCode.ROLE_USER) { + return notEligible("INDIVIDUAL_USER", gateway); + } + + String orgId = SecurityUtil.getUserOrg().getId(); + List options = credentialRepository.findAllByOrgid(orgId).stream() + .filter(c -> APP_STATUS_ACTIVE.equals(c.getAppstatus())) + .map(this::toOption) + .collect(Collectors.toList()); + + if (options.isEmpty()) { + return notEligible("NO_CREDENTIAL", gateway); + } + + return DjbTestbedContextDto.builder() + .eligible(true) + .reason(null) + .credentials(options) + .gateway(gateway) + .build(); + } + + /** + * 선택 앱의 시크릿 단건. 로그인 + 비-ROLE_USER + 본인 소유(orgId 일치) 검증 후 반환. + * 소유 불일치/비대상은 {@link AccessDeniedException}(→ 403). + */ + public DjbCredentialSecretDto getCredentialSecret(String clientId, String apiId) { + if (!SecurityUtil.isAuthenticated()) { + throw new AccessDeniedException("로그인이 필요합니다."); + } + PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser(); + if (user.getRoleCode() == RoleCode.ROLE_USER) { + throw new AccessDeniedException("개인사용자는 이용할 수 없습니다."); + } + + String orgId = SecurityUtil.getUserOrg().getId(); + Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId) + .orElseThrow(() -> new AccessDeniedException("본인 소유의 앱이 아닙니다.")); + + DjbAuthType authType = resolveAuthType(apiId); + + return DjbCredentialSecretDto.builder() + .clientId(credential.getClientid()) + .clientSecret(credential.getClientsecret()) + .authType(authType) + .build(); + } + + /** + * apiId(=TSEAIHE01.eaisvcname, admin 입력 컨벤션)로 authtype 조회 후 매핑. + * 미매핑/미존재는 {@code DjbUnsupportedAuthTypeException}(→ 400). + */ + public DjbAuthType resolveAuthType(String apiId) { + String authtype = eaiMessageRepository.findById(apiId) + .map(EAIMessageEntity::getAuthtype) + .orElse(null); + return DjbAuthType.fromAuthtype(authtype); + } + + private DjbCredentialOptionDto toOption(Credential c) { + return DjbCredentialOptionDto.builder() + .clientId(c.getClientid()) + .clientName(c.getClientname()) + .appStatus(c.getAppstatus()) + .build(); + } + + private GatewayInfo buildGatewayInfo(String portalOrigin) { + return GatewayInfo.builder() + .mode(gatewayProperty.resolveGatewayMode()) + .baseUrl(gatewayProperty.baseUrl()) + .tokenUrl(gatewayProperty.resolveTokenUrl(portalOrigin)) + .apiKeyHeader(gatewayProperty.apiKeyHeader()) + .oauthTokenHeader(gatewayProperty.oauthHeader()) + .build(); + } + + private DjbTestbedContextDto notEligible(String reason, GatewayInfo gateway) { + return DjbTestbedContextDto.builder() + .eligible(false) + .reason(reason) + .credentials(Collections.emptyList()) + .gateway(gateway) + .build(); + } +} diff --git a/src/main/resources/static/plugins/swaggerUI/djb-swagger-i18n.js b/src/main/resources/static/plugins/swaggerUI/djb-swagger-i18n.js new file mode 100644 index 0000000..1c8f11b --- /dev/null +++ b/src/main/resources/static/plugins/swaggerUI/djb-swagger-i18n.js @@ -0,0 +1,161 @@ +/*! + * djb-swagger-i18n.js + * Swagger UI 영문 라벨을 한글로 치환하는 i18n 패치 (방법 B - DOM patch) + * 배포 경로 권장: static/plugins/swaggerUI/djb-swagger-i18n.js + * index.html 의 swagger-initializer.js 직전 또는 직후에