Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 310919130c | |||
| 3c103ad3a4 | |||
| 5d70962521 | |||
| e8d5c8ca03 | |||
| c403118289 | |||
| a91b56ed11 | |||
| 8d232734b1 | |||
| 06c18e437b | |||
| 97b9d2161a | |||
| 0c3b63a5b1 | |||
| 582a3e0f69 | |||
| c1ae12e1ca | |||
| 77aea79363 | |||
| aa5e769148 | |||
| ee8e9e3529 | |||
| 82948a13ce | |||
| f79e528fec | |||
| 6f776ed72a | |||
| e242d3ed06 | |||
| d7abe2c2e5 | |||
| 84d92fced9 | |||
| 53468c3b83 | |||
| 198f6fc82a | |||
| 21c500610c | |||
| 687d4798d4 | |||
| 5adfd42751 | |||
| 6694715195 | |||
| 02f9d41334 |
+6
-3
@@ -92,10 +92,13 @@ pipeline {
|
||||
sh '''
|
||||
set -eu
|
||||
cd build/libs
|
||||
sha1sum eapim-portal.war eapim-portal-boot.war > SHA1SUMS
|
||||
sha256sum eapim-portal.war eapim-portal-boot.war > SHA256SUMS
|
||||
for f in eapim-portal.war eapim-portal-boot.war; do
|
||||
sha1sum "$f" > "$f.sha1"
|
||||
sha256sum "$f" > "$f.sha256"
|
||||
md5sum "$f" > "$f.md5"
|
||||
done
|
||||
'''
|
||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/SHA1SUMS,build/libs/SHA256SUMS', fingerprint: true
|
||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5', fingerprint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,10 +91,13 @@ pipeline {
|
||||
sh '''
|
||||
set -eu
|
||||
cd build/libs
|
||||
sha1sum eapim-portal.war eapim-portal-boot.war > SHA1SUMS
|
||||
sha256sum eapim-portal.war eapim-portal-boot.war > SHA256SUMS
|
||||
for f in eapim-portal.war eapim-portal-boot.war; do
|
||||
sha1sum "$f" > "$f.sha1"
|
||||
sha256sum "$f" > "$f.sha256"
|
||||
md5sum "$f" > "$f.md5"
|
||||
done
|
||||
'''
|
||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/SHA1SUMS,build/libs/SHA256SUMS', fingerprint: true
|
||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5', fingerprint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -3,6 +3,7 @@ plugins {
|
||||
id 'war'
|
||||
id 'eclipse'
|
||||
id 'idea'
|
||||
id 'org.cyclonedx.bom' version '3.2.4'
|
||||
id 'org.springframework.boot' version '2.7.18'
|
||||
id 'io.spring.dependency-management' version '1.1.3'
|
||||
}
|
||||
@@ -29,9 +30,12 @@ dependencies {
|
||||
|
||||
implementation 'com.eactive.elink.common:elink-common-data:4.5.5'
|
||||
|
||||
// implementation 'io.swagger.core.v3:swagger-core:2.2.25'
|
||||
// implementation 'io.swagger.parser.v3:swagger-parser:2.1.23'
|
||||
implementation fileTree(dir: 'libs/swagger', include: ['*.jar'])
|
||||
// OpenAPI 3.0/3.1 spec 지원 (Nexus/Maven 해석). 기존 libs/swagger fileTree 대체.
|
||||
// swagger 최신 릴리스(2.2.52/2.1.45)도 SpecVersion 은 V30/V31 까지만 — OpenAPI 3.2 는
|
||||
// 아직 swagger-core/parser 어느 버전도 미지원. 지원 추가 시 버전만 상향하면 됨. JDK8 호환.
|
||||
implementation 'io.swagger.core.v3:swagger-core:2.2.52' // io.swagger.v3.oas.models.*, io.swagger.v3.core.util.Json
|
||||
implementation 'io.swagger.parser.v3:swagger-parser:2.1.45' // io.swagger.parser.OpenAPIParser, io.swagger.v3.parser.*
|
||||
implementation 'io.swagger:swagger-annotations:1.6.14' // io.swagger.annotations.ApiModelProperty (CommissionSearch)
|
||||
|
||||
implementation project(':elink-online-core-jpa')
|
||||
implementation project(':elink-portal-common')
|
||||
@@ -170,6 +174,7 @@ test {
|
||||
|
||||
bootWar {
|
||||
archiveFileName = "eapim-portal-boot.war"
|
||||
mainClass = 'com.eactive.apim.portal.PortalApplication'
|
||||
}
|
||||
|
||||
war {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||
+34
-9
@@ -15,7 +15,11 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
@Controller
|
||||
@RequestMapping("/agreements")
|
||||
public class AgreementsController {
|
||||
public static final String TERMS_AGREEMENTS = "fragment/kjbank/terms_agreements";
|
||||
public static final String TERMS_AGREEMENTS = "fragment/djbank/terms_agreements";
|
||||
|
||||
/** 개인정보처리방침은 약관 페이지에서 분리되어 제주은행 공식 사이트 외부 링크로 대체됨 */
|
||||
public static final String PRIVACY_POLICY_EXTERNAL_URL =
|
||||
"https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do";
|
||||
|
||||
private final AgreementsFacade agreementsFacade;
|
||||
|
||||
@@ -28,28 +32,49 @@ public class AgreementsController {
|
||||
public String showTerms(@RequestParam(required = false) String tab,
|
||||
@RequestParam(required = false) String publishedOn,
|
||||
Model model) {
|
||||
String currentTab = tab != null ? tab : "terms";
|
||||
|
||||
// 구 개인정보처리방침 탭(tab=privacy)은 외부 링크로 이동했으므로 외부 URL로 리다이렉트(북마크 호환)
|
||||
if ("privacy".equals(currentTab)) {
|
||||
return "redirect:" + PRIVACY_POLICY_EXTERNAL_URL;
|
||||
}
|
||||
|
||||
AgreementType type;
|
||||
if ("privacy".equals(tab)) {
|
||||
type = AgreementType.PRIVACY_POLICY;
|
||||
} else {
|
||||
type = AgreementType.TERMS_OF_USE;
|
||||
switch (currentTab) {
|
||||
case "privacy-collect":
|
||||
// 개인정보수집동의서 = PRIVACY_COLLECT (신설항목)
|
||||
type = AgreementType.PRIVACY_COLLECT;
|
||||
break;
|
||||
case "notification":
|
||||
type = AgreementType.NOTIFICATION_CONSENT;
|
||||
break;
|
||||
case "terms":
|
||||
default:
|
||||
currentTab = "terms";
|
||||
type = AgreementType.TERMS_OF_USE;
|
||||
break;
|
||||
}
|
||||
|
||||
List<AgreementsDTO> agreementsList = agreementsFacade.getAgreementsList(String.valueOf(type));
|
||||
model.addAttribute("agreementsList", agreementsList);
|
||||
|
||||
AgreementsDTO selectedAgreement = publishedOn != null && !publishedOn.isEmpty() ?
|
||||
findAgreementByDate(agreementsList, publishedOn) : agreementsList.get(0);
|
||||
// 해당 약관 종류가 아직 시드되지 않았을 수 있으므로 빈 리스트 방어
|
||||
AgreementsDTO selectedAgreement = null;
|
||||
if (agreementsList != null && !agreementsList.isEmpty()) {
|
||||
selectedAgreement = publishedOn != null && !publishedOn.isEmpty() ?
|
||||
findAgreementByDate(agreementsList, publishedOn) : agreementsList.get(0);
|
||||
}
|
||||
|
||||
model.addAttribute("selectedAgreement", selectedAgreement);
|
||||
model.addAttribute("selectedDate", publishedOn);
|
||||
|
||||
model.addAttribute("isTermsOfUse", type == AgreementType.TERMS_OF_USE);
|
||||
model.addAttribute("isPrivacyPolicy", type == AgreementType.PRIVACY_POLICY);
|
||||
model.addAttribute("isPrivacyCollect", type == AgreementType.PRIVACY_COLLECT);
|
||||
model.addAttribute("isNotification", type == AgreementType.NOTIFICATION_CONSENT);
|
||||
model.addAttribute("agreementTitle", type.getDescription());
|
||||
model.addAttribute("agreementType", type.getCode());
|
||||
|
||||
model.addAttribute("currentTab", tab != null ? tab : "terms");
|
||||
model.addAttribute("currentTab", currentTab);
|
||||
|
||||
return TERMS_AGREEMENTS;
|
||||
}
|
||||
|
||||
+7
-2
@@ -71,8 +71,9 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
|
||||
throw new NotFoundException("현재 시행중인 이용약관이 없습니다.");
|
||||
}
|
||||
|
||||
// 2. 개인정보수집동의서 처리
|
||||
if (privacyCollectType != null && privacyCollectType.isPrivacyCollect()) {
|
||||
// 2. 개인정보수집동의서 처리 (개인정보수집동의서 = PRIVACY_COLLECT 신설항목 포함)
|
||||
if (privacyCollectType != null
|
||||
&& (privacyCollectType.isPrivacyCollect() || privacyCollectType == AgreementType.PRIVACY_COLLECT)) {
|
||||
Optional<Agreements> privacyCollect = agreementsService
|
||||
.findLastesBeforeDate(
|
||||
privacyCollectType,
|
||||
@@ -199,6 +200,10 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
|
||||
defaultAgreement.setName("개인정보처리방침");
|
||||
defaultAgreement.setContents("현재 개인정보처리방침을 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
|
||||
break;
|
||||
case "PRIVACY_COLLECT":
|
||||
defaultAgreement.setName("개인정보수집동의서");
|
||||
defaultAgreement.setContents("현재 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
|
||||
break;
|
||||
case "PRIVACY_COLLECT_IND":
|
||||
defaultAgreement.setName("개인용 개인정보수집동의서");
|
||||
defaultAgreement.setContents("현재 개인용 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
|
||||
|
||||
+25
-15
@@ -2,15 +2,15 @@ package com.eactive.apim.portal.apps.apis.controller;
|
||||
|
||||
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
|
||||
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||
import com.eactive.apim.portal.apps.apiservice.service.ApiSpecService;
|
||||
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedSpecServerRewriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Optional;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.FileCopyUtils;
|
||||
@@ -22,35 +22,45 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/apis")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TestbedSpecController {
|
||||
|
||||
@Autowired
|
||||
private ApiSpecInfoService apiSpecInfoService;
|
||||
private final ApiSpecInfoService apiSpecInfoService;
|
||||
private final DjbTestbedSpecServerRewriter serverRewriter;
|
||||
|
||||
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<String> getSwagger(@PathVariable String id) {
|
||||
public ResponseEntity<String> getSwagger(@PathVariable String id, HttpServletRequest request) {
|
||||
String json = buildSpecJson(id, request);
|
||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
|
||||
public ResponseEntity<String> getSwaggerYaml(@PathVariable String id, HttpServletRequest request) {
|
||||
String json = buildSpecJson(id, request);
|
||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
|
||||
}
|
||||
|
||||
/** default 토큰 spec 또는 저장 spec(서버 sentinel → 설정별 실주소 치환)을 JSON 으로 반환. 없으면 null. */
|
||||
private String buildSpecJson(String id, HttpServletRequest request) {
|
||||
if (DEFAULT_TOKEN_API_ID.equals(id)) {
|
||||
try {
|
||||
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
|
||||
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()));
|
||||
return ResponseEntity.ok().body(content);
|
||||
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
|
||||
return serverRewriter.rewriteServer(content, null, request);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read default token api spec file", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
|
||||
|
||||
if (!spec.isPresent()) {
|
||||
StringUtils.hasText(spec.get().getTestbedSpec());
|
||||
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
|
||||
return null;
|
||||
}
|
||||
return ResponseEntity.ok().body(spec.get().getTestbedSpec());
|
||||
|
||||
return serverRewriter.rewriteServer(spec.get().getTestbedSpec(), spec.get(), request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.eactive.apim.portal.apps.apis.filter;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
@@ -20,6 +21,20 @@ public class APISender {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(APISender.class);
|
||||
|
||||
// 테스트베드 프록시 연결/응답 타임아웃 — DjbTestbedGatewayProperty(djb.gateway.timeout, 단위: 초) 단일 기준.
|
||||
private final DjbTestbedGatewayProperty gatewayProperty;
|
||||
|
||||
public APISender(DjbTestbedGatewayProperty gatewayProperty) {
|
||||
this.gatewayProperty = gatewayProperty;
|
||||
}
|
||||
|
||||
/** connect/read 타임아웃 적용. 반드시 connect(getOutputStream/getResponseCode) 이전에 호출. */
|
||||
private void applyTimeout(HttpURLConnection connection) {
|
||||
int t = gatewayProperty.timeoutMillis();
|
||||
connection.setConnectTimeout(t);
|
||||
connection.setReadTimeout(t);
|
||||
}
|
||||
|
||||
public String requestPost(String uri, String requestBody) throws IOException {
|
||||
|
||||
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
|
||||
@@ -58,6 +73,7 @@ public class APISender {
|
||||
|
||||
URL endpoint = new URL(appendUriAndParams(uri, params));
|
||||
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
||||
applyTimeout(connection);
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setRequestProperty("Accept", "application/json");
|
||||
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||
@@ -82,6 +98,7 @@ public class APISender {
|
||||
|
||||
URL endpoint = new URL(appendUriAndParams(uri, params));
|
||||
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
||||
applyTimeout(connection);
|
||||
connection.setRequestMethod("POST");
|
||||
|
||||
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||
@@ -125,10 +142,11 @@ public class APISender {
|
||||
return response.toString();
|
||||
}
|
||||
|
||||
private static HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
|
||||
private HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
|
||||
URL endpoint = new URL(uri);
|
||||
|
||||
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
||||
applyTimeout(connection);
|
||||
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
|
||||
|
||||
@@ -4,6 +4,8 @@ package com.eactive.apim.portal.apps.apis.filter;
|
||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||
import com.eactive.apim.portal.common.util.ApplicationContextUtil;
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
@@ -20,6 +22,7 @@ import javax.servlet.ServletRequest;
|
||||
import javax.servlet.ServletResponse;
|
||||
import javax.servlet.annotation.WebFilter;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -65,7 +68,23 @@ public class ApiTesterFilter implements Filter {
|
||||
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
|
||||
String url = httpServletRequest.getHeader("original-url");
|
||||
|
||||
if (url.contains("/api/v1/oauth/token")){
|
||||
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
|
||||
if (url == null || url.trim().isEmpty()) {
|
||||
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 실제 프록시 호출(토큰/gw/mock)은 네트워크 오류·타임아웃도 응답 content-type(JSON)에 맞춰
|
||||
// 반환하기 위해 try 로 감싼다.
|
||||
try {
|
||||
|
||||
// 게이트웨이 모드에 따라 OAuth 토큰 발급 요청을 mock 또는 실 게이트웨이 forward 로 분기 (DJPGPT0001)
|
||||
DjbTestbedGatewayProperty gatewayProperty = ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class);
|
||||
DjbGatewayMode gatewayMode = gatewayProperty.resolveGatewayMode();
|
||||
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|
||||
|| url.contains(gatewayProperty.tokenPath());
|
||||
|
||||
if (tokenRequest) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
BufferedReader reader = httpServletRequest.getReader();
|
||||
String line;
|
||||
@@ -74,82 +93,174 @@ public class ApiTesterFilter implements Filter {
|
||||
}
|
||||
String body = sb.toString();
|
||||
|
||||
// Parse request parameters
|
||||
Map<String, String> params = new HashMap<>();
|
||||
String[] pairs = body.split("&");
|
||||
for (String pair : pairs) {
|
||||
String[] keyValue = pair.split("=");
|
||||
if (keyValue.length == 2) {
|
||||
params.put(keyValue[0], keyValue[1]);
|
||||
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
|
||||
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
|
||||
Map<String, String> params = new HashMap<>();
|
||||
String[] pairs = body.split("&");
|
||||
for (String pair : pairs) {
|
||||
String[] keyValue = pair.split("=");
|
||||
if (keyValue.length == 2) {
|
||||
params.put(keyValue[0], keyValue[1]);
|
||||
}
|
||||
}
|
||||
String scope = params.getOrDefault("scope", "default");
|
||||
|
||||
String token = "{\n" +
|
||||
" \"access_token\": \"djbank_gw_sample_token\",\n" +
|
||||
" \"token_type\": \"bearer\",\n" +
|
||||
" \"expires_in\": 86400,\n" +
|
||||
" \"scope\": \""+scope +"\",\n" +
|
||||
" \"jti\": \""+ UUID.randomUUID().toString()+"\"\n" +
|
||||
"}";
|
||||
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(token);
|
||||
} else {
|
||||
// GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만)
|
||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put("Content-Type", "application/x-www-form-urlencoded");
|
||||
headers.put("Accept", "application/json");
|
||||
String target = gatewayProperty.baseUrl() + gatewayProperty.tokenPath();
|
||||
String tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
|
||||
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(tokenResponse);
|
||||
}
|
||||
String scope = params.getOrDefault("scope", "default");
|
||||
|
||||
String token = "{\n" +
|
||||
" \"access_token\": \"djbank_gw_sample_token\",\n" +
|
||||
" \"token_type\": \"bearer\",\n" +
|
||||
" \"expires_in\": 86400,\n" +
|
||||
" \"scope\": \""+scope +"\",\n" +
|
||||
" \"jti\": \""+ UUID.randomUUID().toString()+"\"\n" +
|
||||
"}";
|
||||
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(token);
|
||||
|
||||
} else {
|
||||
ApiSpecInfoDto apiSpecInfoDto = apiSpecInfoDtoService.selectDetailByURLAndMethod(parseUri(url), httpServletRequest.getMethod());
|
||||
|
||||
if (apiSpecInfoDto.getResponseType() == null || apiSpecInfoDto.getResponseType().equals("sample")) {
|
||||
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
|
||||
if (apiSpecInfoDto == null) {
|
||||
writeJson(response, HttpServletResponse.SC_NOT_FOUND,
|
||||
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
String responseType = apiSpecInfoDto.getResponseType();
|
||||
|
||||
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
|
||||
if (responseType == null || responseType.equalsIgnoreCase("sample")) {
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
|
||||
return;
|
||||
}
|
||||
|
||||
// gw / mock: 실주소로 forward. swagger 서버 표시(DjbTestbedSpecServerRewriter)와 동일하게 맞춘다.
|
||||
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
|
||||
// - mock : ApiSpecInfo.mockUrl (기존 동작)
|
||||
boolean gw = "gw".equalsIgnoreCase(responseType);
|
||||
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String header = headerNames.nextElement();
|
||||
headers.put(header, httpServletRequest.getHeader(header));
|
||||
}
|
||||
headers.remove("original-url");
|
||||
headers.remove("original-api-id");
|
||||
|
||||
String targetUri;
|
||||
Map<String, String[]> paramMap;
|
||||
if (gw) {
|
||||
// original-url 이 이미 (게이트웨이주소 + path + query) 전체이므로 그대로 대상 URL 로 사용.
|
||||
// 프록시 대상 호스트가 바뀌므로 Host 헤더는 제거해 대상 호스트로 자동 설정되게 한다.
|
||||
headers.remove("host");
|
||||
headers.remove("Host");
|
||||
targetUri = url;
|
||||
paramMap = new HashMap<>();
|
||||
} else {
|
||||
String mockUrl = apiSpecInfoDto.getMockUrl();
|
||||
String method = apiSpecInfoDto.getApiMethod();
|
||||
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
|
||||
// mock: 저장된 mockUrl 로 forward하고, original-url 의 쿼리스트링을 재부착 (기존 동작 유지)
|
||||
targetUri = apiSpecInfoDto.getMockUrl();
|
||||
paramMap = extractQueryParams(url);
|
||||
}
|
||||
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String header = headerNames.nextElement();
|
||||
headers.put(header, httpServletRequest.getHeader(header));
|
||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||
String responseStr;
|
||||
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
|
||||
responseStr = apiSender.requestPost(targetUri, headers, paramMap, readBody(httpServletRequest));
|
||||
} else {
|
||||
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
|
||||
}
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(responseStr);
|
||||
}
|
||||
|
||||
} catch (java.net.SocketTimeoutException e) {
|
||||
// 연결/응답 타임아웃 (djb.gateway.timeout 초과)
|
||||
logger.warn("테스트베드 프록시 타임아웃: {}", e.getMessage());
|
||||
writeJson(response, HttpServletResponse.SC_GATEWAY_TIMEOUT,
|
||||
"{\"error\":\"게이트웨이 응답 시간 초과(timeout)\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
} catch (IOException e) {
|
||||
// 연결 실패 등 네트워크 오류
|
||||
logger.error("테스트베드 프록시 호출 실패", e);
|
||||
writeJson(response, HttpServletResponse.SC_BAD_GATEWAY,
|
||||
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
} catch (Exception e) {
|
||||
// 그 외 예기치 못한 오류도 JSON 으로 반환
|
||||
logger.error("테스트베드 프록시 처리 오류", e);
|
||||
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
|
||||
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
}
|
||||
}
|
||||
|
||||
/** 요청 본문 전체를 문자열로 읽는다. */
|
||||
private String readBody(HttpServletRequest request) throws IOException {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
BufferedReader reader = request.getReader();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** original-url 의 쿼리스트링(?a=1&b=2)을 파라미터 맵으로 파싱. */
|
||||
private Map<String, String[]> extractQueryParams(String originalUrl) {
|
||||
Map<String, String[]> paramMap = new HashMap<>();
|
||||
String[] parts = originalUrl.split("\\?");
|
||||
if (parts.length > 1) {
|
||||
for (String param : parts[1].split("&")) {
|
||||
String[] kv = param.split("=");
|
||||
if (kv.length > 1) {
|
||||
paramMap.put(kv[0], new String[]{kv[1]});
|
||||
}
|
||||
Map<String, String[]> paramMap = new HashMap<>();
|
||||
|
||||
String originalUrl = headers.get("original-url");
|
||||
//sprlit original url with ? get the second part then split with & put into paramMap
|
||||
|
||||
String[] originalUrlArr = originalUrl.split("\\?");
|
||||
if (originalUrlArr.length > 1) {
|
||||
String[] paramArr = originalUrlArr[1].split("&");
|
||||
for (String param : paramArr) {
|
||||
String[] paramKeyValue = param.split("=");
|
||||
if (paramKeyValue.length > 1) {
|
||||
paramMap.put(paramKeyValue[0], new String[]{paramKeyValue[1]});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
headers.remove("original-url");
|
||||
headers.remove("original-api-id");
|
||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||
|
||||
String responseStr = "";
|
||||
if (method.equalsIgnoreCase("post")) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
BufferedReader reader = httpServletRequest.getReader();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
String body = sb.toString();
|
||||
responseStr = apiSender.requestPost(mockUrl, headers, paramMap, body);
|
||||
|
||||
} else {
|
||||
responseStr = apiSender.requestGet(mockUrl, headers, paramMap);
|
||||
}
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(responseStr);
|
||||
}
|
||||
}
|
||||
return paramMap;
|
||||
}
|
||||
|
||||
/** 상태코드 + JSON 본문 응답. */
|
||||
private void writeJson(ServletResponse response, int status, String json) throws IOException {
|
||||
((HttpServletResponse) response).setStatus(status);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(json);
|
||||
}
|
||||
|
||||
/** JSON 문자열 값에 넣기 위한 최소 escape (예외 메시지 등). */
|
||||
private String escapeJson(String s) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
switch (c) {
|
||||
case '\\': sb.append("\\\\"); break;
|
||||
case '"': sb.append("\\\""); break;
|
||||
case '\n': sb.append("\\n"); break;
|
||||
case '\r': sb.append("\\r"); break;
|
||||
case '\t': sb.append("\\t"); break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
sb.append(String.format("\\u%04x", (int) c));
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -31,4 +31,21 @@ public class ApprovalDTO {
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
private LocalDateTime approvalDate;
|
||||
|
||||
private String expectEndDate; //예상완료일 (yyyyMMdd)
|
||||
|
||||
/** 예상완료일(yyyyMMdd)의 한글 요일. 값이 없거나 형식이 맞지 않으면 빈 문자열. (예: "토") */
|
||||
public String getExpectEndDateDayOfWeek() {
|
||||
if (expectEndDate == null || expectEndDate.length() < 8) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return java.time.LocalDate
|
||||
.parse(expectEndDate.substring(0, 8), java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"))
|
||||
.getDayOfWeek()
|
||||
.getDisplayName(java.time.format.TextStyle.SHORT, java.util.Locale.KOREAN);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,31 +3,72 @@ package com.eactive.apim.portal.apps.auth.service;
|
||||
import com.eactive.apim.portal.portaluser.entity.TwoFactorAuth;
|
||||
import com.eactive.apim.portal.portaluser.repository.TwoFactorAuthRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
@Component
|
||||
public class AuthNumberStorage {
|
||||
|
||||
private static final String HASH_ALGORITHM = "HmacSHA256";
|
||||
|
||||
private final TwoFactorAuthRepository twoFactorAuthRepository;
|
||||
|
||||
/**
|
||||
* recipient(이메일/휴대폰)는 암호화 컬럼이라 값으로 동등 조회/중복정리가 불가능하다.
|
||||
* 그래서 recipient 평문을 결정적 해시(HMAC-SHA256)로 변환해 별도 조회 키(recipientKey)로 사용한다.
|
||||
* 평문을 그대로 노출하지 않도록 pepper 로 기존 암호화 키를 재사용한다.
|
||||
*/
|
||||
@Value("${encryption.key:kjbank_portal_application_1357902}")
|
||||
private String hashPepper;
|
||||
|
||||
@Autowired
|
||||
public AuthNumberStorage(TwoFactorAuthRepository twoFactorAuthRepository) {
|
||||
this.twoFactorAuthRepository = twoFactorAuthRepository;
|
||||
}
|
||||
|
||||
public void saveAuthNumber(String recipientKey, String authNumber, LocalDateTime expiresAt) {
|
||||
twoFactorAuthRepository.deleteAllByRecipient(recipientKey);
|
||||
String lookupKey = hashRecipient(recipientKey);
|
||||
twoFactorAuthRepository.deleteAllByRecipientKey(lookupKey);
|
||||
TwoFactorAuth auth = new TwoFactorAuth(recipientKey, authNumber, expiresAt);
|
||||
auth.setRecipientKey(lookupKey);
|
||||
twoFactorAuthRepository.save(auth);
|
||||
}
|
||||
|
||||
public Optional<TwoFactorAuth> getAuthNumber(String recipientKey) {
|
||||
return twoFactorAuthRepository.findByRecipient(recipientKey);
|
||||
return twoFactorAuthRepository.findByRecipientKey(hashRecipient(recipientKey));
|
||||
}
|
||||
|
||||
public void deleteAuthNumber(String recipientKey) {
|
||||
twoFactorAuthRepository.deleteById(recipientKey);
|
||||
twoFactorAuthRepository.deleteAllByRecipientKey(hashRecipient(recipientKey));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* recipient 평문을 결정적 해시(HMAC-SHA256, 소문자 hex)로 변환한다.
|
||||
* 동일 입력 → 동일 키이므로 저장/조회/정리에서 동등 매칭이 가능하다.
|
||||
*/
|
||||
private String hashRecipient(String recipient) {
|
||||
if (recipient == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Mac mac = Mac.getInstance(HASH_ALGORITHM);
|
||||
mac.init(new SecretKeySpec(hashPepper.getBytes(StandardCharsets.UTF_8), HASH_ALGORITHM));
|
||||
byte[] digest = mac.doFinal(recipient.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder(digest.length * 2);
|
||||
for (byte b : digest) {
|
||||
sb.append(Character.forDigit((b >> 4) & 0xF, 16));
|
||||
sb.append(Character.forDigit(b & 0xF, 16));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException("2FA recipient 해시 생성 실패", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -5,14 +5,18 @@ import com.eactive.apim.portal.apps.community.partnership.mapper.PartnershipAppl
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
|
||||
import com.eactive.apim.portal.file.entity.FileInfo;
|
||||
import com.eactive.apim.portal.file.service.FileService;
|
||||
import com.eactive.apim.portal.file.service.FileTypeContext;
|
||||
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -21,6 +25,8 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
||||
private final PartnershipApplicationService partnershipApplicationService;
|
||||
private final PartnershipApplicationMapper partnershipApplicationMapper;
|
||||
private final FileService fileService;
|
||||
// portal-admin 알림 발행기(범용). Q&A 등록 알림과 동일 컴포넌트를 재사용한다.
|
||||
private final CommunityAdminNotifier portalAdminNotifier;
|
||||
|
||||
|
||||
@Override
|
||||
@@ -43,5 +49,15 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
||||
partnershipApplication.setFileId(file.getFileId());
|
||||
}
|
||||
partnershipApplicationService.createPartnershipApplication(partnershipApplication);
|
||||
|
||||
// 개선요청/제휴 게시물 등록 시 portal-admin 에게 알림 (INQUIRY_CREATED 와 동일 패턴)
|
||||
PortalAuthenticatedUser writer = SecurityUtil.getPortalAuthenticatedUser();
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("partnershipId", partnershipApplication.getId());
|
||||
params.put("bizSubject", partnershipApplication.getBizSubject());
|
||||
if (writer != null) {
|
||||
params.put("writerName", writer.getUserName());
|
||||
}
|
||||
portalAdminNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ import com.eactive.apim.portal.apps.community.qna.mapper.InquiryMapper;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.service.InquiryAdminNotifier;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.qna.entity.Inquiry;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
@@ -26,7 +26,7 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
||||
|
||||
private final InquiryService inquiryService;
|
||||
private final InquiryMapper inquiryMapper;
|
||||
private final InquiryAdminNotifier inquiryAdminNotifier;
|
||||
private final CommunityAdminNotifier inquiryAdminNotifier;
|
||||
|
||||
@Override
|
||||
public Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user) {
|
||||
|
||||
-19
@@ -152,25 +152,6 @@ public class AccountRecoveryController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/resend_verification_email")
|
||||
public ResponseEntity<Map<String, String>> resendVerificationEmail(@RequestParam String loginId) {
|
||||
Map<String, String> response = new HashMap<>();
|
||||
|
||||
try {
|
||||
portalUserAuthService.resendVerificationEmail(loginId);
|
||||
response.put("success", "인증 이메일이 재발송되었습니다.");
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
response.put("error", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
response.put("error", "이메일 발송 중 오류가 발생했습니다.");
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/dormant_account")
|
||||
public String showDormantAccountPage(HttpSession session,Model model) {
|
||||
|
||||
|
||||
+4
@@ -19,6 +19,10 @@ public interface UserSessionRepository extends JpaRepository<UserSession, String
|
||||
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId AND s.sessionId <> :currentSessionId")
|
||||
int forceLogoutOtherSessions(@Param("loginId") String loginId, @Param("currentSessionId") String currentSessionId);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId")
|
||||
int forceLogoutAllSessions(@Param("loginId") String loginId);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM UserSession s WHERE s.lastAccessTime < :cutoffTime")
|
||||
int deleteExpiredSessions(@Param("cutoffTime") LocalDateTime cutoffTime);
|
||||
|
||||
@@ -23,6 +23,9 @@ public class UserSessionService {
|
||||
private static final String PROPERTY_NAME = "session.timeout.minutes";
|
||||
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
|
||||
|
||||
/** 논리적 만료(lastAccessTime + 타임아웃) 이후 물리적 삭제까지 두는 안전 마진(분) */
|
||||
private static final long CLEANUP_MARGIN_MINUTES = 1;
|
||||
|
||||
private final UserSessionRepository userSessionRepository;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
@@ -71,6 +74,18 @@ public class UserSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 사용자의 모든 세션에 강제 로그아웃 플래그 설정.
|
||||
* 관리자의 권한 변경(위임/회수/소속제외)을 대상 사용자의 활성 세션에 반영하기 위해 사용한다.
|
||||
*/
|
||||
@Transactional
|
||||
public void forceLogoutAllSessions(String loginId) {
|
||||
int count = userSessionRepository.forceLogoutAllSessions(loginId);
|
||||
if (count > 0) {
|
||||
log.info("전체 강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 세션의 강제 로그아웃 여부 확인
|
||||
*/
|
||||
@@ -134,13 +149,15 @@ public class UserSessionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 만료 세션 정리 (5분 주기). 타임아웃의 2배 이상 지난 세션 삭제 (안전 마진).
|
||||
* 만료 세션 정리 (1분 주기).
|
||||
* 논리적 만료(lastAccessTime + 타임아웃)가 지난 세션을 소량 마진({@value #CLEANUP_MARGIN_MINUTES}분) 후 삭제한다.
|
||||
* heartbeat/활동이 멈춰 더 이상 갱신되지 않는 세션(닫힌 탭·크래시·네트워크 단절 등)을 신속히 제거한다.
|
||||
*/
|
||||
@Scheduled(fixedRate = 300000)
|
||||
@Scheduled(fixedRate = 60000)
|
||||
@Transactional
|
||||
public void cleanupExpiredSessions() {
|
||||
int timeoutMinutes = getSessionTimeoutMinutes();
|
||||
LocalDateTime cutoff = LocalDateTime.now().minusMinutes((long) timeoutMinutes * 2);
|
||||
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(timeoutMinutes + CLEANUP_MARGIN_MINUTES);
|
||||
int deleted = userSessionRepository.deleteExpiredSessions(cutoff);
|
||||
if (deleted > 0) {
|
||||
log.info("만료 세션 정리 - 삭제 수: {}", deleted);
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
package com.eactive.apim.portal.apps.user.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
||||
import com.eactive.apim.portal.apps.user.dto.PasswordChangeRequestDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalOrgRegistrationDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.dto.*;
|
||||
import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade;
|
||||
import com.eactive.apim.portal.apps.user.facade.UserFacade;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
|
||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.servlet.http.Cookie;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -33,14 +23,19 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import javax.servlet.http.Cookie;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
@Secured("ROLE_ACCOUNT")
|
||||
@RequiredArgsConstructor
|
||||
@@ -53,6 +48,7 @@ public class AccountController {
|
||||
private final AgreementsFacade agreementsFacade;
|
||||
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final UserSessionService userSessionService;
|
||||
|
||||
|
||||
@PostMapping("/confirm_password")
|
||||
@@ -105,6 +101,12 @@ public class AccountController {
|
||||
session.removeAttribute("success");
|
||||
session.removeAttribute("redirectUrl");
|
||||
|
||||
// 세션 무효화 전에 DB 세션 레코드를 정리한다.
|
||||
// SecurityContextLogoutHandler 는 HTTP 세션만 invalidate 하고 UserSession DB 레코드는
|
||||
// 남기므로(정상 로그아웃의 PortalLogoutSuccessHandler 경로를 우회), 정리하지 않으면
|
||||
// 재로그인 시 check-duplicate 가 이 옛 세션을 활성으로 보고 "이미 접속중" 으로 오탐한다.
|
||||
userSessionService.removeSession(session.getId());
|
||||
|
||||
new SecurityContextLogoutHandler().logout(request, response,
|
||||
SecurityContextHolder.getContext().getAuthentication());
|
||||
|
||||
@@ -142,8 +144,8 @@ public class AccountController {
|
||||
// ROLE_USER인 경우 초대 여부 확인
|
||||
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
|
||||
java.util.Optional<UserInvitation> pendingInvitation =
|
||||
userInvitationRepository.findFirstByInvitationEmailAndStatus(
|
||||
user.getEmailAddr(), InvitationStatus.PENDING);
|
||||
userInvitationRepository.findFirstByInvitationMobileAndStatus(
|
||||
PhoneNumberUtil.normalize(currentUser.getMobileNumber()), InvitationStatus.PENDING);
|
||||
|
||||
if (pendingInvitation.isPresent()) {
|
||||
mav.addObject("hasPendingInvitation", true);
|
||||
@@ -262,7 +264,7 @@ public class AccountController {
|
||||
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
|
||||
model.addAttribute("registrationType", "corporate");
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("registrationType", "corporate");
|
||||
return "apps/mypage/orgTransfer";
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.user.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
||||
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||
import com.eactive.apim.portal.apps.user.validator.EmailValidator;
|
||||
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -22,18 +23,22 @@ public class AuthController {
|
||||
private final AuthFacade authFacade;
|
||||
private final CellPhoneValidator cellPhoneValidator;
|
||||
private final EmailValidator emailValidator;
|
||||
private final PortalUserService portalUserService;
|
||||
|
||||
|
||||
public AuthController(AuthFacade authFacade, CellPhoneValidator cellPhoneValidator, EmailValidator emailValidator) {
|
||||
public AuthController(AuthFacade authFacade, CellPhoneValidator cellPhoneValidator, EmailValidator emailValidator,
|
||||
PortalUserService portalUserService) {
|
||||
this.authFacade = authFacade;
|
||||
this.cellPhoneValidator = cellPhoneValidator;
|
||||
this.emailValidator = emailValidator;
|
||||
this.portalUserService = portalUserService;
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/request_auth_number")
|
||||
public ValidationResponse requestAuthNumber(
|
||||
@RequestParam(required = false) String mobileNumber,
|
||||
@RequestParam(required = false) String purpose,
|
||||
HttpSession session) {
|
||||
|
||||
ValidationResponse response = new ValidationResponse();
|
||||
@@ -44,6 +49,16 @@ public class AuthController {
|
||||
return response;
|
||||
}
|
||||
|
||||
// 회원가입 흐름에서는 인증번호 발송 전에 휴대폰 번호 중복을 미리 검증한다.
|
||||
// (중복 번호를 인증까지 마친 뒤 가입 단계에서야 거절되는 것을 방지)
|
||||
// mobileNumber 는 저장 포맷과 동일한 하이픈 포함 형태이므로 sanitize 전 값으로 조회한다.
|
||||
if ("signup".equals(purpose) && portalUserService.isMobileDuplicateCheckEnabled()
|
||||
&& portalUserService.existsByMobileNumber(mobileNumber)) {
|
||||
response.setValid(false);
|
||||
response.setMessage("이미 가입된 휴대폰 번호입니다.");
|
||||
return response;
|
||||
}
|
||||
|
||||
String sanitizedMobile = HtmlUtils.htmlEscape(mobileNumber.replace("-", ""));
|
||||
|
||||
// 이전 세션 데이터 정리
|
||||
|
||||
+5
-2
@@ -29,7 +29,8 @@ public class OrgRegisterController {
|
||||
model.addAttribute("authTtl", portalProperties.getAuthTtl());
|
||||
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||
return MAIN_ORG_REGISTER;
|
||||
}
|
||||
|
||||
@@ -74,6 +75,7 @@ public class OrgRegisterController {
|
||||
|
||||
if (response.isValid()) {
|
||||
model.addAttribute("message", response.getMessage());
|
||||
model.addAttribute("registrationType", "corporate");
|
||||
return MAIN_REGISTER_RESULT;
|
||||
} else {
|
||||
setModelForErrorOrg(model, orgDTO, response.getMessage());
|
||||
@@ -91,6 +93,7 @@ public class OrgRegisterController {
|
||||
model.addAttribute("portalOrg", orgDTO);
|
||||
model.addAttribute("error", errorMessage);
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||
}
|
||||
}
|
||||
|
||||
+34
-15
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
|
||||
import com.eactive.apim.portal.apps.user.facade.UserManFacade;
|
||||
import com.eactive.apim.portal.common.dto.ResponseDTO;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -18,12 +19,16 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class UserManRestController {
|
||||
|
||||
private final UserManFacade userManFacade;
|
||||
private final CellPhoneValidator cellPhoneValidator;
|
||||
|
||||
// 1. Cancel invitation
|
||||
@PostMapping("/cancel-invitation")
|
||||
public ResponseDTO cancelInvitation(@RequestBody PortalUserDTO user) {
|
||||
userManFacade.cancelInvitation(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
|
||||
return new ResponseDTO(200, "SUCCESS", "초대가 취소되었습니다.");
|
||||
boolean notifyConsent = user.isNotifyConsent();
|
||||
userManFacade.cancelInvitation(SecurityUtil.getPortalAuthenticatedUser(), user.getId(), notifyConsent);
|
||||
return new ResponseDTO(200, "SUCCESS", notifyConsent
|
||||
? "초대가 취소되었습니다.<br>취소 알림이 발송되었습니다."
|
||||
: "초대가 취소되었습니다.<br>(알림 수신 미동의로 메시지는 발송되지 않았습니다.)");
|
||||
}
|
||||
|
||||
// 2. Inactivate user
|
||||
@@ -40,25 +45,25 @@ public class UserManRestController {
|
||||
return new ResponseDTO(200, "SUCCESS", "사용자가 활성화되었습니다.");
|
||||
}
|
||||
|
||||
//5. register new user
|
||||
//5. register new user (휴대폰 번호 기반 초대)
|
||||
@PostMapping("/invite")
|
||||
public ResponseDTO sendInvitation(@RequestBody PortalUserDTO newUser) {
|
||||
String email = newUser.getEmailAddr();
|
||||
String mobile = newUser.getMobileNumber();
|
||||
|
||||
// 이메일 형식 검증
|
||||
if (email == null || email.trim().isEmpty()) {
|
||||
return new ResponseDTO(400, "ERROR", "이메일 주소를 입력해주세요.");
|
||||
// 휴대폰 번호 검증
|
||||
if (mobile == null || mobile.trim().isEmpty()) {
|
||||
return new ResponseDTO(400, "ERROR", "휴대폰 번호를 입력해주세요.");
|
||||
}
|
||||
if (!cellPhoneValidator.isValid(mobile, null)) {
|
||||
return new ResponseDTO(400, "ERROR", "올바른 휴대폰 번호 형식을 입력해주세요.");
|
||||
}
|
||||
|
||||
// 이메일 정규식 검증
|
||||
String emailPattern = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$";
|
||||
if (!email.matches(emailPattern)) {
|
||||
return new ResponseDTO(400, "ERROR", "올바른 이메일 형식을 입력해주세요.");
|
||||
}
|
||||
boolean notifyConsent = newUser.isNotifyConsent();
|
||||
userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), mobile, notifyConsent);
|
||||
|
||||
userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), email);
|
||||
|
||||
return new ResponseDTO(200, "SUCCESS", "요청 메일이 발송되었습니다.");
|
||||
return new ResponseDTO(200, "SUCCESS", notifyConsent
|
||||
? "초대 메시지가 발송되었습니다."
|
||||
: "초대가 등록되었습니다.<br>(알림 수신 미동의로 메시지는 발송되지 않았습니다.)");
|
||||
}
|
||||
|
||||
@PostMapping("/change-role")
|
||||
@@ -69,6 +74,20 @@ public class UserManRestController {
|
||||
return new ResponseDTO(200, "SUCCESS", "변경되었습니다. 확인 버튼을 누르시면 계정을 로그아웃 합니다.");
|
||||
}
|
||||
|
||||
// 관리자 -> 이용자 변경 (본인 제외)
|
||||
@PostMapping("/revoke-manager")
|
||||
public ResponseDTO revokeManager(@RequestBody PortalUserDTO user) {
|
||||
userManFacade.revokeManager(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
|
||||
return new ResponseDTO(200, "SUCCESS", "이용자로 변경되었습니다.");
|
||||
}
|
||||
|
||||
// 소속 제외 -> 개인이용자로 전환
|
||||
@PostMapping("/remove-from-org")
|
||||
public ResponseDTO removeFromOrg(@RequestBody PortalUserDTO user) {
|
||||
userManFacade.removeFromOrg(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
|
||||
return new ResponseDTO(200, "SUCCESS", "소속에서 제외되어 개인이용자로 변경되었습니다.");
|
||||
}
|
||||
|
||||
// 6. Resend invitation
|
||||
@PostMapping("/resend-invitation")
|
||||
public ResponseDTO resendInvitation(@RequestBody PortalUserDTO user) {
|
||||
|
||||
+109
-46
@@ -4,11 +4,14 @@ import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
||||
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
|
||||
import com.eactive.apim.portal.apps.user.facade.UserRegisterFacade;
|
||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
@@ -16,11 +19,15 @@ import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
|
||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
@@ -50,6 +57,7 @@ public class UserRegisterController {
|
||||
|
||||
private final PortalUserService portalUserService;
|
||||
private final UserRegisterFacade userRegisterFacade;
|
||||
private final AuthFacade authFacade;
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalOrgRepository portalOrgRepository;
|
||||
private final AgreementsFacade agreementsFacade;
|
||||
@@ -57,6 +65,7 @@ public class UserRegisterController {
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final EncryptionUtil encryptionUtil;
|
||||
private final AgreementValidator agreementValidator;
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
|
||||
@GetMapping("/signup")
|
||||
public String showSignupSelection() {
|
||||
@@ -67,8 +76,6 @@ public class UserRegisterController {
|
||||
public String getUserAgreement(@RequestParam(name = "invitation", required = false) String invitationToken, HttpSession session, Model model) {
|
||||
boolean isInvited = false;
|
||||
String orgName = null;
|
||||
String email = null;
|
||||
String[] parts = null;
|
||||
|
||||
if (invitationToken != null) {
|
||||
// 8글자 토큰 사용
|
||||
@@ -80,29 +87,23 @@ public class UserRegisterController {
|
||||
String orgId = invitation.get().getOrgId();
|
||||
Optional<PortalOrg> org = portalOrgRepository.findById(orgId);
|
||||
orgName = org.map(PortalOrg::getOrgName).orElse(null);
|
||||
email = invitation.get().getInvitationEmail();
|
||||
parts = email.split("@");
|
||||
}
|
||||
}
|
||||
|
||||
model.addAttribute("registrationType", isInvited ? "corporate" : "personal");
|
||||
model.addAttribute("isInvited", isInvited);
|
||||
model.addAttribute("orgName", orgName);
|
||||
model.addAttribute("email", email);
|
||||
model.addAttribute("loginId", email);
|
||||
|
||||
if (parts != null) {
|
||||
model.addAttribute("emailId", parts[0]);
|
||||
model.addAttribute("domain", parts[1]);
|
||||
} else {
|
||||
model.addAttribute("emailId", "");
|
||||
model.addAttribute("domain", "");
|
||||
}
|
||||
// 휴대폰 번호 기반 초대 전환: 이메일/로그인ID 는 가입자가 직접 입력한다
|
||||
model.addAttribute("email", "");
|
||||
model.addAttribute("loginId", "");
|
||||
model.addAttribute("emailId", "");
|
||||
model.addAttribute("domain", "");
|
||||
|
||||
model.addAttribute("authTtl", portalProperties.getAuthTtl());
|
||||
model.addAttribute("portalUser", new PortalUserRegistrationDTO());
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement(isInvited ? "PRIVACY_COLLECT_ORG" : "PRIVACY_COLLECT_IND"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||
|
||||
return MAIN_USER_REGISTER;
|
||||
|
||||
@@ -138,8 +139,21 @@ public class UserRegisterController {
|
||||
}
|
||||
// 성공 시 PRG 패턴 적용: 결과 페이지로 리다이렉트
|
||||
session.removeAttribute("invitationToken");
|
||||
|
||||
// 개인 가입자 중 이메일 인증 대상(READY 상태)은 회원가입 직후 바로 이메일 인증 단계로 이동
|
||||
if (invitationToken == null) {
|
||||
Optional<PortalUser> registered = portalUserService.findByLoginId(portalUserRegistrationDTO.getLoginId());
|
||||
if (registered.isPresent()
|
||||
&& PortalUserEnums.UserStatus.READY.equals(registered.get().getUserStatus())) {
|
||||
session.setAttribute("signupVerificationEmail", registered.get().getEmailAddr());
|
||||
return "redirect:/signup/verification-email";
|
||||
}
|
||||
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
|
||||
return "redirect:/signup/complete";
|
||||
}
|
||||
|
||||
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
|
||||
return invitationToken != null ? "redirect:/signup/complete/corporate" : "redirect:/signup/complete";
|
||||
return "redirect:/signup/complete/corporate";
|
||||
} catch (Exception e) {
|
||||
setModelForError(session, model, agreement, portalUserRegistrationDTO,
|
||||
"처리 중 오류가 발생했습니다: " + e.getMessage());
|
||||
@@ -169,6 +183,57 @@ public class UserRegisterController {
|
||||
return MAIN_EMAIL_COMPLETED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 직후 이메일 인증 페이지.
|
||||
* 가입 단계에서 세션에 저장된 이메일이 있어야 진입 가능하다.
|
||||
*/
|
||||
@GetMapping("/signup/verification-email")
|
||||
public String showSignupVerificationEmail(HttpSession session, Model model) {
|
||||
String email = (String) session.getAttribute("signupVerificationEmail");
|
||||
if (email == null) {
|
||||
return "redirect:/login";
|
||||
}
|
||||
model.addAttribute("email", email);
|
||||
return "apps/register/signupVerificationEmail";
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 이메일 인증코드 발송. 임의 이메일 타깃 방지를 위해 세션에 저장된 가입 이메일만 사용한다.
|
||||
*/
|
||||
@PostMapping("/signup/send-verification-code")
|
||||
public ResponseEntity<ValidationResponse> sendSignupVerificationCode(HttpSession session) {
|
||||
String email = (String) session.getAttribute("signupVerificationEmail");
|
||||
if (email == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ValidationResponse(false, "유효하지 않은 요청입니다. 회원가입을 다시 진행해주세요."));
|
||||
}
|
||||
ValidationResponse response = authFacade.requestAuth(email, "EMAIL");
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 이메일 인증코드 검증. 성공 시 사용자 상태를 READY → ACTIVE 로 활성화한다.
|
||||
*/
|
||||
@PostMapping("/signup/verify-email-code")
|
||||
public ResponseEntity<ValidationResponse> verifySignupEmailCode(@RequestBody Map<String, String> request,
|
||||
HttpSession session) {
|
||||
String email = (String) session.getAttribute("signupVerificationEmail");
|
||||
if (email == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ValidationResponse(false, "유효하지 않은 요청입니다. 회원가입을 다시 진행해주세요."));
|
||||
}
|
||||
|
||||
String code = request.get("code");
|
||||
ValidationResponse response = authFacade.verifyAuthNumber(email, code);
|
||||
|
||||
if (response.isValid()) {
|
||||
PortalUser user = portalUserService.findByEmailAddr(email);
|
||||
portalUserService.activateUser(user.getId());
|
||||
session.removeAttribute("signupVerificationEmail");
|
||||
}
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@GetMapping("/signup/decision")
|
||||
public String showDecisionPage(@RequestParam(name = "invitation", required = false) String invitationToken,
|
||||
HttpSession session,
|
||||
@@ -181,6 +246,11 @@ public class UserRegisterController {
|
||||
String decodedToken = decodeInvitationToken(invitationToken);
|
||||
session.setAttribute("decisionToken", decodedToken);
|
||||
|
||||
// 이미 로그인된 상태면 수락 화면으로 직행
|
||||
if (SecurityUtil.isAuthenticated()) {
|
||||
return "redirect:/signup/decision_process";
|
||||
}
|
||||
|
||||
Optional<UserInvitation> invitation = userInvitationRepository.findByToken(decodedToken);
|
||||
|
||||
if (!invitation.isPresent() || invitation.get().getStatus() != UserInvitationEnums.InvitationStatus.PENDING) {
|
||||
@@ -188,9 +258,9 @@ public class UserRegisterController {
|
||||
return "redirect:/";
|
||||
}
|
||||
|
||||
// 이메일로 사용자 정보 조회
|
||||
String email = invitation.get().getInvitationEmail();
|
||||
Optional<PortalUser> portalUser = portalUserRepository.findPortalUserByEmailAddr(email);
|
||||
// 휴대폰 번호로 사용자 정보 조회
|
||||
String mobile = invitation.get().getInvitationMobile();
|
||||
Optional<PortalUser> portalUser = portalUserRepository.findAllByMobileNumber(mobile).stream().findFirst();
|
||||
|
||||
if (!portalUser.isPresent()) {
|
||||
model.addAttribute("error", "사용자 정보를 찾을 수 없습니다.");
|
||||
@@ -219,13 +289,14 @@ public class UserRegisterController {
|
||||
public String showDecisionProcessPage(HttpSession session, Model model) {
|
||||
session.removeAttribute("decisionToken");
|
||||
|
||||
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationEmailAndStatus(SecurityUtil.getPortalAuthenticatedUser().getLoginId(), UserInvitationEnums.InvitationStatus.PENDING);
|
||||
String mobile = PhoneNumberUtil.normalize(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber());
|
||||
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(mobile, UserInvitationEnums.InvitationStatus.PENDING);
|
||||
|
||||
if (!invitation.isPresent()) {
|
||||
model.addAttribute("error", "유효하지 않은 초대입니다");
|
||||
return "redirect:/";
|
||||
}
|
||||
if (!SecurityUtil.getPortalAuthenticatedUser().getLoginId().equals(invitation.get().getInvitationEmail())) {
|
||||
if (mobile == null || !mobile.equals(invitation.get().getInvitationMobile())) {
|
||||
model.addAttribute("error", "유효하지 않은 초대입니다");
|
||||
return "redirect:/";
|
||||
}
|
||||
@@ -241,7 +312,9 @@ public class UserRegisterController {
|
||||
model.addAttribute("orgName", org.get().getOrgName());
|
||||
model.addAttribute("invitationCode", invitation.get().getToken());
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
|
||||
model.addAttribute("registrationType", "corporate");
|
||||
|
||||
return "apps/register/userDecisionProcess";
|
||||
@@ -260,7 +333,7 @@ public class UserRegisterController {
|
||||
|
||||
agreementValidator.validate(agreement, bindingResult);
|
||||
|
||||
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationEmailAndStatus(SecurityUtil.getPortalAuthenticatedUser().getLoginId(), UserInvitationEnums.InvitationStatus.PENDING);
|
||||
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(PhoneNumberUtil.normalize(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber()), UserInvitationEnums.InvitationStatus.PENDING);
|
||||
|
||||
if (action.equalsIgnoreCase("accept") && bindingResult.hasErrors()) {
|
||||
|
||||
@@ -270,13 +343,20 @@ public class UserRegisterController {
|
||||
model.addAttribute("userName", SecurityUtil.getPortalAuthenticatedUser().getUsername());
|
||||
model.addAttribute("orgName", org.get().getOrgName());
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
|
||||
model.addAttribute("registrationType", "corporate");
|
||||
|
||||
return "apps/register/userDecisionProcess";
|
||||
} else {
|
||||
ValidationResponse response = userRegisterFacade.processInvitation(action, invitation.get());
|
||||
|
||||
// 수락 성공 시 재로그인 없이 본인 세션 권한 갱신 (ROLE_USER → ROLE_CORP_USER)
|
||||
if ("accept".equalsIgnoreCase(action) && response.isValid()) {
|
||||
portalUserAuthService.reloadCurrentAuthentication();
|
||||
}
|
||||
|
||||
// 초대 처리 완료 후 세션에서 초대 관련 속성 제거
|
||||
session.removeAttribute("pendingInvitation");
|
||||
session.removeAttribute("pendingInvitationToken");
|
||||
@@ -294,24 +374,6 @@ public class UserRegisterController {
|
||||
return "redirect:/";
|
||||
}
|
||||
|
||||
@GetMapping("/validate_email")
|
||||
public String validateUserEmail(@RequestParam(name = "token", required = false) String token) {
|
||||
String decodedToken = new String(Base64.decode(token));
|
||||
try {
|
||||
String decToken = encryptionUtil.decrypt(decodedToken);
|
||||
String[] tokens = decToken.split(":");
|
||||
boolean approvalRequired = portalUserService.activateUser(tokens[1]);
|
||||
if (approvalRequired) {
|
||||
return "apps/register/emailValidationResultCorpManager";
|
||||
} else {
|
||||
return "apps/register/emailValidationResultUser";
|
||||
}
|
||||
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException |
|
||||
BadPaddingException e) {
|
||||
throw new IllegalArgumentException("인증 정보 오류. 관리자 문의하세요.");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 에러 발생 시 모델 세팅을 위한 private 메서드
|
||||
private void setModelForError(HttpSession session, Model model, UserAgreementDTO agreement,
|
||||
@@ -325,7 +387,8 @@ public class UserRegisterController {
|
||||
model.addAttribute("agreement", agreement);
|
||||
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_IND"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||
model.addAttribute("msgType", "sms");
|
||||
}
|
||||
|
||||
@@ -437,10 +500,10 @@ public class UserRegisterController {
|
||||
session.removeAttribute("invitationCodeAttempts");
|
||||
session.removeAttribute("invitationCodeLockoutUntil");
|
||||
|
||||
// 기존 사용자 여부 확인
|
||||
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(invitation.getInvitationEmail());
|
||||
// 기존 사용자 여부 확인 (휴대폰 번호 기반)
|
||||
boolean existingUser = !portalUserRepository.findAllByMobileNumber(invitation.getInvitationMobile()).isEmpty();
|
||||
|
||||
if (existingUser.isPresent()) {
|
||||
if (existingUser) {
|
||||
// 기존 사용자: 초대 수락 페이지로 이동
|
||||
return "redirect:/signup/decision?invitation=" + normalizedCode;
|
||||
} else {
|
||||
|
||||
@@ -23,6 +23,9 @@ public class PortalUserDTO implements Serializable {
|
||||
|
||||
private String emailAddr;
|
||||
|
||||
// 초대 시 알림 수신 동의 여부 (false 면 초대 레코드만 생성, 메시지 미발송)
|
||||
private boolean notifyConsent;
|
||||
|
||||
private PortalOrgDTO portalOrg;
|
||||
|
||||
private UserStatus userStatus;
|
||||
|
||||
@@ -192,7 +192,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
// 사용자 생성 및 기관 연결
|
||||
PortalUser newUser = portalUserService.createUserWithOrg(orgDTO, newOrg, "corporate");
|
||||
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
|
||||
approvalService.createUserApproval(newUser);
|
||||
// 11.28 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
||||
@@ -240,7 +240,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
|
||||
portalUserRepository.save(existingUser);
|
||||
agreementsFacade.deleteUserAgreements(existingUser.getId()); //기존 개인약관 동의 삭제
|
||||
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
|
||||
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
|
||||
approvalService.createUserApproval(existingUser);
|
||||
|
||||
@@ -274,7 +274,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
portalUserRepository.save(existingUser);
|
||||
|
||||
// 새로운 법인용 개인정보수집동의서 저장
|
||||
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
|
||||
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
|
||||
approvalService.createUserApproval(existingUser);
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
|
||||
import com.eactive.apim.portal.common.exception.NotFoundException;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.DurationParser;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
|
||||
import com.eactive.apim.portal.invitation.event.UserInvitationCancelEvent;
|
||||
@@ -18,6 +20,8 @@ import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.UserStatus;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||
@@ -52,6 +56,9 @@ public class UserManFacade {
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final UserLogRepository userLogRepository;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
private final CellPhoneValidator cellPhoneValidator;
|
||||
private final UserSessionService userSessionService;
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
|
||||
public Page<PortalUserDTO> getUsers(PortalOrgDTO userOrg, Pageable pageable) {
|
||||
return portalUserRepository
|
||||
@@ -94,23 +101,35 @@ public class UserManFacade {
|
||||
dto.setMobileNumber(null);
|
||||
}
|
||||
|
||||
public void sendInvitation(PortalUser sender, String emailAddr) {
|
||||
// 이메일 형식 검증
|
||||
if (emailAddr == null || emailAddr.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("이메일 주소를 입력해주세요.");
|
||||
/**
|
||||
* 법인 개발자 초대 (휴대폰 번호 기반).
|
||||
*
|
||||
* @param sender 초대를 보내는 법인 관리자
|
||||
* @param mobileRaw 초대 대상 휴대폰 번호 (하이픈 유무 무관)
|
||||
* @param notifyConsent 알림 수신 동의 여부. false 면 초대 레코드만 생성하고 메시지 발송은 스킵한다.
|
||||
*/
|
||||
public void sendInvitation(PortalUser sender, String mobileRaw, boolean notifyConsent) {
|
||||
// 휴대폰 번호 형식 검증
|
||||
if (mobileRaw == null || mobileRaw.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("휴대폰 번호를 입력해주세요.");
|
||||
}
|
||||
if (!cellPhoneValidator.isValid(mobileRaw, null)) {
|
||||
throw new IllegalArgumentException("올바른 휴대폰 번호 형식을 입력해주세요.");
|
||||
}
|
||||
|
||||
// 이메일 정규식 검증
|
||||
String emailPattern = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$";
|
||||
if (!emailAddr.matches(emailPattern)) {
|
||||
throw new IllegalArgumentException("올바른 이메일 형식을 입력해주세요.");
|
||||
}
|
||||
// 저장 포맷(하이픈 포함, 예: 010-1234-5678)으로 정규화 — PortalUser.mobileNumber 저장 포맷과
|
||||
// 일치시켜야 결정적 암호화 equality 조회가 매칭된다.
|
||||
String mobile = normalizeMobile(mobileRaw);
|
||||
|
||||
// 중복 초대 확인
|
||||
checkDuplicateInvitation(sender.getPortalOrg().getId(), emailAddr);
|
||||
checkDuplicateInvitation(sender.getPortalOrg().getId(), mobile);
|
||||
|
||||
//기존 사용자인지 확인
|
||||
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(emailAddr);
|
||||
// 기존 사용자인지 휴대폰 번호로 확인 (mobileNumber 는 유니크가 아니므로 다건 가능)
|
||||
List<PortalUser> matched = portalUserRepository.findAllByMobileNumber(mobile);
|
||||
if (matched.size() > 1) {
|
||||
throw new IllegalArgumentException("해당 휴대폰 번호로 가입된 계정이 여러 건 존재하여 초대할 수 없습니다. 관리자에게 문의해주세요.");
|
||||
}
|
||||
Optional<PortalUser> existingUser = matched.isEmpty() ? Optional.empty() : Optional.of(matched.get(0));
|
||||
|
||||
if (existingUser.isPresent()) {
|
||||
PortalUser user = existingUser.get();
|
||||
@@ -119,7 +138,12 @@ public class UserManFacade {
|
||||
}
|
||||
}
|
||||
|
||||
String token = createInvitation(sender, emailAddr);
|
||||
String token = createInvitation(sender, mobile);
|
||||
|
||||
// 알림 수신 미동의 시 초대 레코드만 생성하고 메시지 발송은 스킵
|
||||
if (!notifyConsent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// {"CRPT_NM":"%corpName%", "MNGR_NM":"%managerName%", "CUST_ID":"%loginId%", "AUTH_NO": "%authNumber%", "NAME": "%loginId%"}
|
||||
MessageRecipient recipient;
|
||||
@@ -129,7 +153,7 @@ public class UserManFacade {
|
||||
params.put("corpName", sender.getPortalOrg().getOrgName());
|
||||
params.put("authNumber", token); // 8-character invitation code
|
||||
|
||||
// 기존 사용자인 경우 회원 이름 사용, 비회원인 경우 이메일 사용
|
||||
// 기존 사용자인 경우 회원 이름 사용, 비회원인 경우 휴대폰 번호 사용
|
||||
if (existingUser.isPresent()) {
|
||||
PortalUser user = existingUser.get();
|
||||
params.put("loginId", user.getUserName());
|
||||
@@ -137,26 +161,36 @@ public class UserManFacade {
|
||||
params.put("url", "signup/decision?invitation=" + token);
|
||||
|
||||
recipient = MessageRecipient.of(user);
|
||||
// MessageRecipient.of() 는 유선전화(phoneNumber)를 채우므로 휴대폰 번호로 보정
|
||||
recipient.setPhone(user.getMobileNumber());
|
||||
} else {
|
||||
params.put("loginId", emailAddr);
|
||||
params.put("NAME", emailAddr);
|
||||
params.put("loginId", mobile);
|
||||
params.put("NAME", mobile);
|
||||
params.put("url", "signup/portalUser?invitation=" + token);
|
||||
|
||||
String emailUsername = emailAddr.contains("@") ? emailAddr.substring(0, emailAddr.indexOf("@")) : emailAddr;
|
||||
recipient = MessageRecipient.builder()
|
||||
.userId(emailAddr)
|
||||
.username(emailUsername)
|
||||
.userId(mobile)
|
||||
.username(mobile)
|
||||
.phone(mobile)
|
||||
.build();
|
||||
}
|
||||
|
||||
messageHandlerService.publishEvent(UserInvitationEvent.KEY, recipient, params);
|
||||
}
|
||||
|
||||
public String createInvitation(PortalUser sender, String emailAddr) {
|
||||
/**
|
||||
* 휴대폰 번호를 저장 포맷(하이픈 포함)으로 정규화한다.
|
||||
* 010-XXXX-XXXX(11자리) / 010-XXX-XXXX(10자리). 형식 불명은 원본 반환(검증에서 차단됨).
|
||||
*/
|
||||
private String normalizeMobile(String raw) {
|
||||
return PhoneNumberUtil.normalize(raw);
|
||||
}
|
||||
|
||||
public String createInvitation(PortalUser sender, String mobile) {
|
||||
UserInvitation newInvitation = new UserInvitation();
|
||||
newInvitation.setAdminId(sender.getId());
|
||||
newInvitation.setOrgId(sender.getPortalOrg().getId());
|
||||
newInvitation.setInvitationEmail(emailAddr);
|
||||
newInvitation.setInvitationMobile(mobile);
|
||||
|
||||
// Set additional required fields
|
||||
newInvitation.setInvitationDate(LocalDateTime.now());
|
||||
@@ -215,6 +249,10 @@ public class UserManFacade {
|
||||
portalUserRepository.save(targetUser);
|
||||
portalUserRepository.save(currentUser);
|
||||
|
||||
// 권한 변경 즉시 반영: 대상(타 세션)은 강제 로그아웃, 본인(현재 세션)은 in-place 재인증
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
portalUserAuthService.reloadCurrentAuthentication();
|
||||
|
||||
MessageRecipient recipient = new MessageRecipient();
|
||||
recipient.setUserId(targetUser.getEmailAddr());
|
||||
recipient.setPhone(targetUser.getMobileNumber());
|
||||
@@ -225,7 +263,57 @@ public class UserManFacade {
|
||||
|
||||
}
|
||||
|
||||
public void cancelInvitation(PortalAuthenticatedUser user, String invitationId) {
|
||||
/**
|
||||
* 법인 관리자 권한 회수 (관리자 → 이용자).
|
||||
* 대상은 본인이 아닌 같은 기관의 ROLE_CORP_MANAGER 여야 한다.
|
||||
*/
|
||||
public void revokeManager(PortalAuthenticatedUser admin, String targetUserId) {
|
||||
PortalUser targetUser = portalUserRepository.findById(targetUserId)
|
||||
.orElseThrow(() -> new NotFoundException("ID [" + targetUserId + "] 사용자를 찾을 수 없습니다. "));
|
||||
|
||||
if (targetUser.getPortalOrg() == null
|
||||
|| !targetUser.getPortalOrg().getId().equals(admin.getPortalOrg().getId())) {
|
||||
throw new IllegalArgumentException("잘 못 된 접근입니다");
|
||||
}
|
||||
if (targetUser.getId().equals(admin.getId())) {
|
||||
throw new IllegalArgumentException("본인의 권한은 변경할 수 없습니다.");
|
||||
}
|
||||
if (targetUser.getRoleCode() != RoleCode.ROLE_CORP_MANAGER) {
|
||||
throw new IllegalArgumentException("관리자만 이용자로 변경할 수 있습니다.");
|
||||
}
|
||||
|
||||
targetUser.setRoleCode(RoleCode.ROLE_CORP_USER);
|
||||
portalUserRepository.save(targetUser);
|
||||
|
||||
// 권한 회수를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 소속 제외 (기관에서 제외 → 개인이용자).
|
||||
* 대상은 본인이 아닌 같은 기관의 사용자여야 한다. portalOrg 를 비우고 ROLE_USER 로 전환한다.
|
||||
*/
|
||||
public void removeFromOrg(PortalAuthenticatedUser admin, String targetUserId) {
|
||||
PortalUser targetUser = portalUserRepository.findById(targetUserId)
|
||||
.orElseThrow(() -> new NotFoundException("ID [" + targetUserId + "] 사용자를 찾을 수 없습니다. "));
|
||||
|
||||
if (targetUser.getPortalOrg() == null
|
||||
|| !targetUser.getPortalOrg().getId().equals(admin.getPortalOrg().getId())) {
|
||||
throw new IllegalArgumentException("잘 못 된 접근입니다");
|
||||
}
|
||||
if (targetUser.getId().equals(admin.getId())) {
|
||||
throw new IllegalArgumentException("본인은 소속에서 제외할 수 없습니다.");
|
||||
}
|
||||
|
||||
targetUser.setPortalOrg(null);
|
||||
targetUser.setRoleCode(RoleCode.ROLE_USER);
|
||||
portalUserRepository.save(targetUser);
|
||||
|
||||
// 소속 제외를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
}
|
||||
|
||||
public void cancelInvitation(PortalAuthenticatedUser user, String invitationId, boolean notifyConsent) {
|
||||
UserInvitation invitation = userInvitationRepository.findById(invitationId)
|
||||
.orElseThrow(() -> new NotFoundException("Invitation not found with id: " + invitationId));
|
||||
|
||||
@@ -237,17 +325,19 @@ public class UserManFacade {
|
||||
throw new IllegalStateException("Invitation is not in a cancelable state");
|
||||
}
|
||||
|
||||
// invitationId 로 사용자 이름 조회, 실패시 id 를 이름으로 대체
|
||||
String name = portalUserRepository.findById(invitation.getInvitationEmail())
|
||||
.map(PortalUser::getUserName)
|
||||
.orElse(invitation.getInvitationEmail());
|
||||
invitation.setStatus(InvitationStatus.CANCELED);
|
||||
userInvitationRepository.save(invitation);
|
||||
|
||||
// name 이 이메일 일 경우 @ 앞부분 만 사용 (안하면 암호화 해야함...)
|
||||
if (name.contains("@")) {
|
||||
name = name.substring(0, name.indexOf("@"));
|
||||
// 알림 수신 미동의 시 취소 알림 메시지 발송은 스킵
|
||||
if (!notifyConsent) {
|
||||
return;
|
||||
}
|
||||
|
||||
invitation.setStatus(InvitationStatus.CANCELED);
|
||||
// invitationMobile 로 사용자 이름 조회, 실패시 마스킹된 번호로 대체
|
||||
String name = portalUserRepository.findAllByMobileNumber(invitation.getInvitationMobile())
|
||||
.stream().findFirst()
|
||||
.map(PortalUser::getUserName)
|
||||
.orElse(StringMaskingUtil.maskMobileNumber(invitation.getInvitationMobile()));
|
||||
|
||||
messageHandlerService.publishEvent(UserInvitationCancelEvent.KEY,
|
||||
MessageRecipient.builder()
|
||||
@@ -260,8 +350,6 @@ public class UserManFacade {
|
||||
}}
|
||||
);
|
||||
|
||||
userInvitationRepository.save(invitation);
|
||||
|
||||
}
|
||||
|
||||
public void inactivateUser(PortalAuthenticatedUser admin, String userId) {
|
||||
@@ -298,8 +386,8 @@ public class UserManFacade {
|
||||
.stream()
|
||||
.map(invitation -> {
|
||||
PortalUserDTO dto = portalUserMapper.pendingUserVO(invitation);
|
||||
dto.setMaskedEmailAddr(StringMaskingUtil.maskEmail(invitation.getInvitationEmail()));
|
||||
dto.setEmailAddr(null); // Clear unmasked email
|
||||
dto.setMaskedMobileNumber(StringMaskingUtil.maskMobileNumber(invitation.getInvitationMobile()));
|
||||
dto.setMobileNumber(null); // Clear unmasked mobile
|
||||
return dto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
@@ -328,32 +416,32 @@ public class UserManFacade {
|
||||
existingInvitation.setStatus(InvitationStatus.CANCELED);
|
||||
userInvitationRepository.save(existingInvitation);
|
||||
|
||||
// 새로운 초대 생성 및 발송
|
||||
sendInvitation(sender, existingInvitation.getInvitationEmail());
|
||||
// 새로운 초대 생성 및 발송 (재발송 시 최초 동의값을 알 수 없으므로 발송함을 기본으로)
|
||||
sendInvitation(sender, existingInvitation.getInvitationMobile(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 중복 초대 확인
|
||||
* 같은 기관에서 같은 이메일로 이미 PENDING 상태인 초대가 있는지 확인
|
||||
* 같은 기관에서 같은 휴대폰 번호로 이미 PENDING 상태인 초대가 있는지 확인
|
||||
*/
|
||||
private void checkDuplicateInvitation(String orgId, String emailAddr) {
|
||||
private void checkDuplicateInvitation(String orgId, String mobile) {
|
||||
// 1. 같은 기관에서 PENDING 상태인 초대가 있는지 확인
|
||||
Optional<UserInvitation> pendingInvitation =
|
||||
userInvitationRepository.findFirstByInvitationEmailAndOrgIdAndStatus(
|
||||
emailAddr, orgId, InvitationStatus.PENDING);
|
||||
userInvitationRepository.findFirstByInvitationMobileAndOrgIdAndStatus(
|
||||
mobile, orgId, InvitationStatus.PENDING);
|
||||
|
||||
if (pendingInvitation.isPresent()) {
|
||||
throw new IllegalArgumentException("이미 초대가 진행 중인 이메일 주소입니다.");
|
||||
throw new IllegalArgumentException("이미 초대가 진행 중인 휴대폰 번호입니다.");
|
||||
}
|
||||
|
||||
// 2. 다른 기관에서 PENDING 상태인 초대가 있는지 확인
|
||||
Optional<UserInvitation> otherOrgPendingInvitation =
|
||||
userInvitationRepository.findFirstByInvitationEmailAndStatus(
|
||||
emailAddr, InvitationStatus.PENDING);
|
||||
userInvitationRepository.findFirstByInvitationMobileAndStatus(
|
||||
mobile, InvitationStatus.PENDING);
|
||||
|
||||
if (otherOrgPendingInvitation.isPresent()
|
||||
&& !otherOrgPendingInvitation.get().getOrgId().equals(orgId)) {
|
||||
throw new IllegalArgumentException("해당 이메일은 다른 기관에서 초대 진행 중입니다.");
|
||||
throw new IllegalArgumentException("해당 휴대폰 번호는 다른 기관에서 초대 진행 중입니다.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-11
@@ -137,12 +137,11 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
return new ValidationResponse(false, "이미 사용 중인 이메일입니다.");
|
||||
}
|
||||
|
||||
// 25.10.01 - 휴대폰 번호 중복이여도 가입 가능
|
||||
// PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber());
|
||||
//
|
||||
// if(existingUser != null) {
|
||||
// return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
|
||||
// }
|
||||
// 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단)
|
||||
if (portalUserService.isMobileDuplicateCheckEnabled()
|
||||
&& portalUserService.existsByMobileNumber(registrationDTO.getMobileNumber())) {
|
||||
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
|
||||
}
|
||||
|
||||
// 3. 사용자 등록 ("personal" 등록 유형으로 가정)
|
||||
PortalUser newUser = portalUserService.registerActiveUser(registrationDTO, "personal");
|
||||
@@ -150,7 +149,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
return new ValidationResponse(false,"사용자 등록에 실패했습니다.");
|
||||
}
|
||||
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_IND);
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
||||
// sendEmailActivation(newUser);
|
||||
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
|
||||
@@ -173,6 +172,12 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
return new ValidationResponse(false,"입력값을 확인해 주세요.");
|
||||
}
|
||||
|
||||
// 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단)
|
||||
if (portalUserService.isMobileDuplicateCheckEnabled()
|
||||
&& portalUserService.existsByMobileNumber(registrationDTO.getMobileNumber())) {
|
||||
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
|
||||
}
|
||||
|
||||
PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber());
|
||||
|
||||
if(existingUser != null) {
|
||||
@@ -190,7 +195,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
}
|
||||
|
||||
agreementsFacade.deleteUserAgreements(newUser.getId()); //이전에 동의한 내역은 삭제
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
|
||||
invitation.setStatus(UserInvitationEnums.InvitationStatus.COMPLETED);
|
||||
invitation.setCompleteDate(LocalDateTime.now());
|
||||
@@ -242,7 +247,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
try {
|
||||
if ("accept".equals(action)) {
|
||||
// 수락 처리
|
||||
PortalUser user = portalUserRepository.findPortalUserByEmailAddr(invitation.getInvitationEmail())
|
||||
PortalUser user = portalUserRepository.findAllByMobileNumber(invitation.getInvitationMobile())
|
||||
.stream().findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("사용자 정보를 찾을 수 없습니다."));
|
||||
|
||||
// 서비스 계층에 위임
|
||||
@@ -253,11 +259,11 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
userInvitationRepository.save(invitation);
|
||||
|
||||
agreementsFacade.deleteUserAgreements(user.getId());
|
||||
agreementsFacade.saveUserAgreements(user.getId(), AgreementType.PRIVACY_COLLECT_ORG);
|
||||
agreementsFacade.saveUserAgreements(user.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
|
||||
ValidationResponse response = new ValidationResponse();
|
||||
response.setValid(true);
|
||||
response.setMessage("초대가 성공적으로 수락되었습니다. 로그아웃 후 다시 로그인 해 주세요.");
|
||||
response.setMessage("초대가 성공적으로 수락되었습니다. 법인회원으로 전환되었습니다.");
|
||||
response.setShowPopup(true);
|
||||
response.setPopupType("success");
|
||||
return response;
|
||||
|
||||
@@ -15,7 +15,7 @@ public abstract class PortalUserMapper implements GenericMapper<PortalUserDTO, P
|
||||
|
||||
public abstract PortalAuthenticatedUser portalUserToAuthenticatedUser(PortalUser portalUser);
|
||||
|
||||
@Mapping(target = "emailAddr", source = "invitationEmail")
|
||||
@Mapping(target = "mobileNumber", source = "invitationMobile")
|
||||
public abstract PortalUserDTO pendingUserVO(UserInvitation invitation);
|
||||
|
||||
public PortalUserDTO toDTO(PortalUser portalUser) {
|
||||
|
||||
+38
-41
@@ -6,6 +6,7 @@ import com.eactive.apim.portal.common.exception.SystemException;
|
||||
import com.eactive.apim.portal.common.exception.UserNotFoundException;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
@@ -30,7 +31,9 @@ import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.xerces.impl.dv.util.Base64;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
@@ -58,21 +61,46 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
// 이메일 소문자 변환 적용 (대소문자 구분 없이 로그인 가능하도록)
|
||||
String normalizedUsername = username != null ? username.toLowerCase() : null;
|
||||
PortalUser portalUser = findByEmailAddr(normalizedUsername);
|
||||
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
|
||||
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
|
||||
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
|
||||
|
||||
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
|
||||
|
||||
roles.forEach(role -> authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(role)));
|
||||
|
||||
return authenticatedUser;
|
||||
|
||||
return buildAuthenticatedUser(portalUser);
|
||||
} catch (UserNotFoundException e) {
|
||||
throw new UsernameNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DB의 PortalUser 로부터 권한(roleCode + 역할 계층)을 채운 PortalAuthenticatedUser 를 생성한다.
|
||||
* 로그인과 세션 재로드가 동일한 권한 구성을 사용하도록 공통화한다.
|
||||
*/
|
||||
public PortalAuthenticatedUser buildAuthenticatedUser(PortalUser portalUser) {
|
||||
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
|
||||
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
|
||||
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
|
||||
|
||||
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
|
||||
roles.forEach(role -> authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(role)));
|
||||
|
||||
return authenticatedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 로그인한 사용자의 권한을 DB에서 다시 읽어 SecurityContext 의 Authentication 을 교체한다.
|
||||
* 재로그인 없이 본인 세션에 역할 변경(초대 수락, 관리자 위임으로 인한 본인 강등 등)을 즉시 반영한다.
|
||||
*/
|
||||
public void reloadCurrentAuthentication() {
|
||||
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
PortalUser fresh = portalUserRepository.findByLoginId(current.getLoginId())
|
||||
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다."));
|
||||
PortalAuthenticatedUser refreshed = buildAuthenticatedUser(fresh);
|
||||
|
||||
UsernamePasswordAuthenticationToken newAuth =
|
||||
new UsernamePasswordAuthenticationToken(refreshed, null, refreshed.getAuthorities());
|
||||
newAuth.setDetails(refreshed);
|
||||
SecurityContextHolder.getContext().setAuthentication(newAuth);
|
||||
}
|
||||
|
||||
public List<PortalUserDTO> findAllUsersByNameAndMobile(String userName, String mobileNumber) {
|
||||
try {
|
||||
if (mobileNumber == null || !mobileNumber.matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
|
||||
@@ -122,37 +150,6 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
messageHandlerService.publishEvent(UserPasswordResetEvent.KEY, recipient, params);
|
||||
}
|
||||
|
||||
public void resendVerificationEmail(String loginId) {
|
||||
|
||||
PortalUser user = portalUserRepository.findByLoginId(loginId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요."));
|
||||
|
||||
if (user.getUserStatus().equals(PortalUserEnums.UserStatus.ACTIVE)) {
|
||||
throw new IllegalArgumentException("이미 인증이 완료된 계정입니다.");
|
||||
}
|
||||
|
||||
// Enum을 직접 전달
|
||||
List<MessageRequest> messageRequests = messageRequestRepository.findByEmailAndMessageCode(
|
||||
user.getLoginId(), MessageCode.USER_VERIFICATION_EMAIL
|
||||
);
|
||||
|
||||
if (!messageRequests.isEmpty()) {
|
||||
messageRequestRepository.deleteAll(messageRequests);
|
||||
}
|
||||
|
||||
HashMap<String, Object> params = new HashMap<>();
|
||||
String tokenValue = user.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + user.getId();
|
||||
|
||||
try {
|
||||
String encToken = encryptionUtil.encrypt(tokenValue);
|
||||
params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
|
||||
MessageRecipient recipient = createMessageRecipient(user);
|
||||
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
|
||||
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
|
||||
throw new SystemException("암호화 모듈 오류");
|
||||
}
|
||||
}
|
||||
|
||||
private MessageRecipient createMessageRecipient(PortalUser user) {
|
||||
MessageRecipient recipient = new MessageRecipient();
|
||||
recipient.setUsername(user.getUserName());
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.user.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
|
||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
@@ -24,6 +25,12 @@ import org.springframework.util.StringUtils;
|
||||
@Service
|
||||
@Transactional
|
||||
public class PortalUserService {
|
||||
|
||||
/** PortalProperty 그룹명 */
|
||||
private static final String PORTAL_PROPERTY_GROUP = "Portal";
|
||||
/** 휴대폰 번호 중복 허용 프로퍼티 키 (true:허용, false:허용안함, 기본 false) */
|
||||
private static final String PROP_MOBILE_DUPLICATE_ALLOW = "user.mobile.duplicate.allow";
|
||||
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalOrgRepository portalOrgRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
@@ -68,6 +75,33 @@ public class PortalUserService {
|
||||
return portalUserRepository.existsByLoginId(normalizedLoginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴대폰 번호 중복 여부 확인.
|
||||
* mobileNumber 는 저장 포맷(하이픈 포함, 예: 010-1234-5678)과 동일한 형태로 전달해야 한다.
|
||||
* (암호화 컬럼이므로 결정적 암호화 기준 평등 조회로 매칭된다)
|
||||
*/
|
||||
public boolean existsByMobileNumber(String mobileNumber) {
|
||||
if (mobileNumber == null || mobileNumber.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return portalUserRepository.existsByMobileNumber(PhoneNumberUtil.normalize(mobileNumber));
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 시 휴대폰 번호 중복 차단 활성 여부.
|
||||
* 공유 프로퍼티(Portal/user.mobile.duplicate.allow)를 true/false 로 해석한다.
|
||||
* - allow=true(또는 레거시 Y): 중복 허용 → 차단 비활성
|
||||
* - allow=false(또는 레거시 N) / 미설정: 중복 허용안함 → 차단 활성 (기본값 false)
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isMobileDuplicateCheckEnabled() {
|
||||
String allow = portalPropertyService
|
||||
.getPortalPropertiesAsMap(PORTAL_PROPERTY_GROUP)
|
||||
.getOrDefault(PROP_MOBILE_DUPLICATE_ALLOW, "false");
|
||||
boolean allowed = "true".equalsIgnoreCase(allow) || "Y".equalsIgnoreCase(allow);
|
||||
return !allowed;
|
||||
}
|
||||
|
||||
public boolean checkOrgHasOtherUsers(PortalOrg portalOrg) {
|
||||
return portalUserRepository.countByPortalOrg(portalOrg) > 1;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.eactive.apim.portal.common.exception;
|
||||
|
||||
import org.springframework.boot.web.servlet.error.ErrorController;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -9,6 +11,8 @@ import org.springframework.web.servlet.ModelAndView;
|
||||
|
||||
import javax.servlet.RequestDispatcher;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
|
||||
/**
|
||||
* Created by Sungpil Hyun
|
||||
@@ -16,6 +20,11 @@ import javax.servlet.http.HttpServletRequest;
|
||||
@Controller
|
||||
public class PortalErrorController implements ErrorController {
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
public PortalErrorController(Environment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
@RequestMapping(value = "/error", method = {RequestMethod.POST, RequestMethod.GET})
|
||||
public ModelAndView handleError(HttpServletRequest request) {
|
||||
@@ -27,10 +36,134 @@ public class PortalErrorController implements ErrorController {
|
||||
}
|
||||
ModelAndView modelAndView = new ModelAndView("error");
|
||||
modelAndView.addObject("status", status);
|
||||
|
||||
if (isProd()) {
|
||||
// prod 환경에서는 내부 오류 정보를 노출하지 않고 이전과 동일한 일반 안내 메시지만 표시
|
||||
modelAndView.addObject("errorTitle", "일시적인 장애로 서비스가 중단되었습니다.");
|
||||
modelAndView.addObject("errorDescription", "서비스 이용에 불편을 드려 죄송합니다. 빠른 서비스를 제공할 수 있게 준비하겠습니다.");
|
||||
} else {
|
||||
modelAndView.addObject("errorTitle", resolveTitle(status));
|
||||
modelAndView.addObject("errorDescription", resolveDescription(status));
|
||||
modelAndView.addObject("errorMessage", resolveErrorMessage(request, status));
|
||||
modelAndView.addObject("activeProfile", resolveActiveProfile());
|
||||
// include-stacktrace=always(로컬 디버깅 프로파일)일 때만 화면에 스택 트레이스까지 노출
|
||||
if (isErrorDetailEnabled()) {
|
||||
Throwable throwable = resolveThrowable(request);
|
||||
if (throwable != null) {
|
||||
modelAndView.addObject("errorException", throwable.getClass().getName());
|
||||
modelAndView.addObject("errorStackTrace", stackTraceAsString(throwable));
|
||||
}
|
||||
}
|
||||
}
|
||||
return modelAndView;
|
||||
}
|
||||
|
||||
return new ModelAndView("redirect:/");
|
||||
}
|
||||
|
||||
private boolean isProd() {
|
||||
return environment.acceptsProfiles(Profiles.of("prod"));
|
||||
}
|
||||
|
||||
/**
|
||||
* server.error.include-stacktrace=always 인 경우에만 화면에 스택 트레이스를 노출한다.
|
||||
* (로컬 디버깅 프로파일에서만 해당 값을 always 로 설정 → 운영/스테이징에는 노출되지 않음)
|
||||
*/
|
||||
private boolean isErrorDetailEnabled() {
|
||||
String includeStacktrace = environment.getProperty("server.error.include-stacktrace", "never");
|
||||
return "always".equalsIgnoreCase(includeStacktrace);
|
||||
}
|
||||
|
||||
private String resolveActiveProfile() {
|
||||
String[] activeProfiles = environment.getActiveProfiles();
|
||||
if (activeProfiles.length == 0) {
|
||||
return "default";
|
||||
}
|
||||
return String.join(", ", activeProfiles);
|
||||
}
|
||||
|
||||
private Throwable resolveThrowable(HttpServletRequest request) {
|
||||
Object exception = request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);
|
||||
if (exception instanceof Throwable) {
|
||||
return (Throwable) exception;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String stackTraceAsString(Throwable throwable) {
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
throwable.printStackTrace(new PrintWriter(stringWriter));
|
||||
return stringWriter.toString();
|
||||
}
|
||||
|
||||
private String resolveTitle(Object status) {
|
||||
HttpStatus httpStatus = resolveHttpStatus(status);
|
||||
if (httpStatus == null) {
|
||||
return "서비스 처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
|
||||
if (httpStatus == HttpStatus.FORBIDDEN) {
|
||||
return "접근이 거부되었습니다.";
|
||||
}
|
||||
if (httpStatus == HttpStatus.NOT_FOUND) {
|
||||
return "요청하신 페이지를 찾을 수 없습니다.";
|
||||
}
|
||||
if (httpStatus.is4xxClientError()) {
|
||||
return "요청을 처리할 수 없습니다.";
|
||||
}
|
||||
if (httpStatus.is5xxServerError()) {
|
||||
return "서비스 처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
return httpStatus.getReasonPhrase();
|
||||
}
|
||||
|
||||
private String resolveDescription(Object status) {
|
||||
HttpStatus httpStatus = resolveHttpStatus(status);
|
||||
if (httpStatus == HttpStatus.FORBIDDEN) {
|
||||
return "요청이 보안 검증을 통과하지 못했습니다.";
|
||||
}
|
||||
if (httpStatus == HttpStatus.NOT_FOUND) {
|
||||
return "주소가 변경되었거나 삭제되었을 수 있습니다.";
|
||||
}
|
||||
if (httpStatus != null && httpStatus.is5xxServerError()) {
|
||||
return "잠시 후 다시 시도해 주세요.";
|
||||
}
|
||||
return "요청 내용을 확인한 뒤 다시 시도해 주세요.";
|
||||
}
|
||||
|
||||
private String resolveErrorMessage(HttpServletRequest request, Object status) {
|
||||
Object requestUri = request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI);
|
||||
HttpStatus httpStatus = resolveHttpStatus(status);
|
||||
StringBuilder message = new StringBuilder();
|
||||
|
||||
message.append("HTTP ").append(status);
|
||||
if (httpStatus != null) {
|
||||
message.append(" ").append(httpStatus.getReasonPhrase());
|
||||
}
|
||||
|
||||
if (requestUri != null) {
|
||||
message.append(" / 요청 경로: ").append(requestUri);
|
||||
}
|
||||
|
||||
Object exceptionMessage = request.getAttribute(RequestDispatcher.ERROR_MESSAGE);
|
||||
if (exceptionMessage != null && !exceptionMessage.toString().trim().isEmpty()) {
|
||||
message.append(" / 원인: ").append(exceptionMessage);
|
||||
} else if (httpStatus == HttpStatus.FORBIDDEN) {
|
||||
message.append(" / 원인: 접근 권한 또는 CSRF 토큰을 확인하세요.");
|
||||
}
|
||||
|
||||
return message.toString();
|
||||
}
|
||||
|
||||
private HttpStatus resolveHttpStatus(Object status) {
|
||||
if (status instanceof Integer) {
|
||||
return HttpStatus.resolve((Integer) status);
|
||||
}
|
||||
try {
|
||||
return HttpStatus.resolve(Integer.parseInt(status.toString()));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
package com.eactive.apim.portal.common.migration;
|
||||
|
||||
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.sql.DataSource;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구.
|
||||
*
|
||||
* <p>배경: {@code @Convert} 컬럼이 평문으로 저장된 레거시 행은, derived query가 검색값을 암호화하면서
|
||||
* 평문 DB값과 불일치해 검색/로그인이 실패한다. 컨버터는 읽기에서 평문/암호문을 자동 구분하고
|
||||
* 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는
|
||||
* 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.</p>
|
||||
*
|
||||
* <p>보안: 오직 127.0.0.1(localhost)에서 직접 호출한 요청만 허용한다. 기본은 dry-run(미변경)이며,
|
||||
* 실제 실행은 {@code dryRun=false}를 명시해야 한다. 작업 완료 후 이 클래스는 제거한다.</p>
|
||||
*
|
||||
* <pre>
|
||||
* # 미리보기(변경 안 함)
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy'
|
||||
* # 실제 실행 (PII 컬럼)
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false'
|
||||
* # audit 컬럼(created_by/last_modified_by, 15개 테이블)까지 포함
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false&includeAudit=true'
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/internal/migration")
|
||||
public class LegacyEncryptionMigrationController {
|
||||
|
||||
/** PII 직접 컬럼 (로그인/검색에 직접 영향) */
|
||||
private static final List<TargetTable> PII_TARGETS = Arrays.asList(
|
||||
new TargetTable("PTL_USER", Arrays.asList("login_id", "email_addr", "phone_number", "mobile_number")),
|
||||
new TargetTable("PTL_MESSAGE_REQUEST", Arrays.asList("email", "phone")),
|
||||
new TargetTable("tseairm02", Arrays.asList("cphnno", "emad")),
|
||||
new TargetTable("PTL_USER_LOG", Arrays.asList("login_id")),
|
||||
new TargetTable("PTL_TWO_FACTOR_AUTH", Arrays.asList("recipient"))
|
||||
);
|
||||
|
||||
/** Auditable(@MappedSuperclass) 상속 테이블의 감사 컬럼 (옵션) */
|
||||
private static final List<String> AUDIT_TABLES = Arrays.asList(
|
||||
"PTL_USER", "ptl_faq", "ptl_org",
|
||||
"DJB_APISTATUS_INCIDENT", "DJB_APISTATUS_INCIDENT_TIMELINE", "DJB_APISTATUS_INCIDENT_API",
|
||||
"ptl_file", "PTL_MESSAGE_TEMPLATE", "ptl_notice", "ptl_terms",
|
||||
"ptl_user_privacy_policy_agreement", "ptl_approval_line",
|
||||
"PTL_INQUIRY_COMMENT", "ptl_inquiry", "ptl_partnership_application"
|
||||
);
|
||||
private static final List<String> AUDIT_COLUMNS = Arrays.asList("created_by", "last_modified_by");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter();
|
||||
|
||||
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource) {
|
||||
// EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다.
|
||||
this.jdbcTemplate = new JdbcTemplate(emsDataSource);
|
||||
}
|
||||
|
||||
@PostMapping("/encrypt-legacy")
|
||||
@Transactional("transactionManager")
|
||||
public Map<String, Object> encryptLegacy(HttpServletRequest request,
|
||||
@RequestParam(defaultValue = "true") boolean dryRun,
|
||||
@RequestParam(defaultValue = "false") boolean includeAudit) {
|
||||
assertLocalOnly(request);
|
||||
assertNotBypass();
|
||||
|
||||
List<TargetTable> targets = new ArrayList<>(PII_TARGETS);
|
||||
if (includeAudit) {
|
||||
for (String table : AUDIT_TABLES) {
|
||||
targets.add(new TargetTable(table, AUDIT_COLUMNS));
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
int totalChanged = 0;
|
||||
for (TargetTable target : targets) {
|
||||
for (String column : target.columns) {
|
||||
Map<String, Object> r = processColumn(target.table, column, dryRun);
|
||||
results.add(r);
|
||||
totalChanged += (int) r.get("changed");
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed");
|
||||
response.put("includeAudit", includeAudit);
|
||||
response.put("totalChanged", totalChanged);
|
||||
response.put("results", results);
|
||||
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={}",
|
||||
dryRun ? "dry-run" : "executed", includeAudit, totalChanged);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 (테이블, 컬럼)의 고유값을 정규화하고, 값이 바뀌는 경우에만 UPDATE.
|
||||
*/
|
||||
private Map<String, Object> processColumn(String table, String column, boolean dryRun) {
|
||||
Map<String, Object> r = new LinkedHashMap<>();
|
||||
r.put("table", table);
|
||||
r.put("column", column);
|
||||
|
||||
List<String> values;
|
||||
try {
|
||||
values = jdbcTemplate.queryForList(
|
||||
"SELECT DISTINCT " + column + " FROM " + table + " WHERE " + column + " IS NOT NULL",
|
||||
String.class);
|
||||
} catch (Exception e) {
|
||||
log.warn("[마이그레이션] 조회 실패 table={} column={} : {}", table, column, e.toString());
|
||||
r.put("distinct", 0);
|
||||
r.put("changed", 0);
|
||||
r.put("error", e.getMessage());
|
||||
return r;
|
||||
}
|
||||
|
||||
int changed = 0;
|
||||
for (String value : values) {
|
||||
String normalized;
|
||||
try {
|
||||
// 평문 → 인코딩, 이미 인코딩 → 동일값 (멱등)
|
||||
normalized = converter.convertToDatabaseColumn(converter.convertToEntityAttribute(value));
|
||||
} catch (Exception e) {
|
||||
log.warn("[마이그레이션] 정규화 실패 table={} column={} : {}", table, column, e.toString());
|
||||
continue;
|
||||
}
|
||||
if (normalized != null && !normalized.equals(value)) {
|
||||
if (!dryRun) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE " + table + " SET " + column + " = ? WHERE " + column + " = ?",
|
||||
normalized, value);
|
||||
}
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
|
||||
r.put("distinct", values.size());
|
||||
r.put("changed", changed);
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* damo-manager 가 bypass 모드면 실행 자체를 거부한다.
|
||||
* bypass 에서는 {@code encrypt} 가 무변환(원문 그대로)이라 정규화가 평문→평문 no-op 이 되어
|
||||
* 마이그레이션이 의미가 없고, "완료"로 오인될 위험이 있다. real/fake 모드로 기동 후 실행해야 한다.
|
||||
*/
|
||||
private void assertNotBypass() {
|
||||
if (converter.isBypassMode()) {
|
||||
log.warn("[마이그레이션] bypass 모드 실행 거부 — 암복호화가 무변환이라 마이그레이션이 무의미함");
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"damo-manager 가 bypass 모드입니다. 암복호화가 무변환(원문 그대로)이라 마이그레이션이 무의미하므로 거부합니다. "
|
||||
+ "real/fake 모드(-Ddamo-manager.enabled=true)로 기동한 뒤 실행하세요.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 127.0.0.1(localhost) 직접 호출만 허용. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다.
|
||||
*/
|
||||
private void assertLocalOnly(HttpServletRequest request) {
|
||||
String remote = request.getRemoteAddr();
|
||||
boolean localAddr = "127.0.0.1".equals(remote)
|
||||
|| "0:0:0:0:0:0:0:1".equals(remote)
|
||||
|| "::1".equals(remote);
|
||||
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
||||
if (!localAddr || viaProxy) {
|
||||
log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}", remote, request.getHeader("X-Forwarded-For"));
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "localhost(127.0.0.1) 직접 호출만 허용됩니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class TargetTable {
|
||||
final String table;
|
||||
final List<String> columns;
|
||||
|
||||
TargetTable(String table, List<String> columns) {
|
||||
this.table = table;
|
||||
this.columns = columns;
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
|
||||
@@ -95,9 +96,10 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
|
||||
// 초대 코드 확인 - ROLE_USER만 확인 (세션에 저장하여 메인 페이지에서 팝업으로 표시)
|
||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
|
||||
// 휴대폰 형식(하이픈 유무)이 달라도 초대와 매칭되도록 정규화 후 조회
|
||||
Optional<UserInvitation> pendingInvitation =
|
||||
userInvitationRepository.findFirstByInvitationEmailAndStatus(
|
||||
user.getLoginId(), InvitationStatus.PENDING);
|
||||
userInvitationRepository.findFirstByInvitationMobileAndStatus(
|
||||
PhoneNumberUtil.normalize(user.getMobileNumber()), InvitationStatus.PENDING);
|
||||
|
||||
if (pendingInvitation.isPresent()) {
|
||||
UserInvitation invitation = pendingInvitation.get();
|
||||
|
||||
@@ -12,8 +12,11 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
import org.springframework.security.web.csrf.CsrfException;
|
||||
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
|
||||
import org.springframework.security.web.session.HttpSessionEventPublisher;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
|
||||
@@ -79,6 +82,13 @@ public class PortalConfigSecurity {
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) {
|
||||
try {
|
||||
// CSRF 토큰을 쿠키(XSRF-TOKEN) 대신 HTTP 세션에 저장한다.
|
||||
// 운영(prod/eapim/devportal)은 동일 호스트(IP:PORT)에 여러 서비스가 떠 있어
|
||||
// 쿠키가 호스트 단위로 공유·과포화되면서 XSRF-TOKEN 쿠키가 누락 → 로그인 403이 발생했다.
|
||||
// 기존 클라이언트(X-XSRF-TOKEN 헤더, _csrf 파라미터)와 호환되도록 헤더명을 고정한다.
|
||||
HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository();
|
||||
csrfTokenRepository.setHeaderName("X-XSRF-TOKEN");
|
||||
|
||||
http
|
||||
.authenticationManager(portalAuthenticationManager)
|
||||
.formLogin(form -> form
|
||||
@@ -90,12 +100,21 @@ public class PortalConfigSecurity {
|
||||
.successHandler(authenticationSuccessHandler))
|
||||
.logout(logout -> logout
|
||||
.logoutRequestMatcher(new AntPathRequestMatcher("/actionLogout.do", "GET"))
|
||||
// LogoutHandler 는 기본 SecurityContextLogoutHandler(세션 무효화)보다 먼저 실행된다.
|
||||
// 세션이 살아있는 이 시점에 DB 세션 레코드를 정리해야 중복세션 오탐("이미 접속중")을 막는다.
|
||||
.addLogoutHandler(logoutSuccessHandler)
|
||||
.logoutSuccessHandler(logoutSuccessHandler))
|
||||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||
.csrfTokenRepository(csrfTokenRepository)
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
|
||||
)
|
||||
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
|
||||
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
|
||||
// 익명으로 인식되지 못하고 기본 403("권한없음")으로 떨어진다.
|
||||
// CSRF 만료는 권한 문제가 아니라 세션 만료이므로 기존 안내(/login?expired=true)로 보낸다.
|
||||
.exceptionHandling(ex -> ex.accessDeniedHandler(csrfAwareAccessDeniedHandler()))
|
||||
// 중복로그인/유휴 만료는 DB 기반 SessionValidationFilter가 처리 (maximumSessions 미사용)
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
||||
@@ -110,6 +129,23 @@ public class PortalConfigSecurity {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF 토큰 누락/불일치는 "권한없음"이 아니라 (대개) 세션 만료다.
|
||||
* 로그인 페이지에 오래 머물러 세션 내 CSRF 토큰이 사라진 경우가 대표적이므로
|
||||
* 기존 세션 만료 안내(login.html의 {@code param.expired})를 재사용해 오해를 막는다.
|
||||
* 그 외 진짜 권한 위반(@Secured 등)은 기본 핸들러에 위임해 403을 유지한다.
|
||||
*/
|
||||
private AccessDeniedHandler csrfAwareAccessDeniedHandler() {
|
||||
AccessDeniedHandlerImpl delegate = new AccessDeniedHandlerImpl();
|
||||
return (request, response, accessDeniedException) -> {
|
||||
if (accessDeniedException instanceof CsrfException) {
|
||||
response.sendRedirect(request.getContextPath() + "/login?expired=true");
|
||||
return;
|
||||
}
|
||||
delegate.handle(request, response, accessDeniedException);
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
public HttpSessionEventPublisher httpSessionEventPublisher() {
|
||||
return new HttpSessionEventPublisher();
|
||||
|
||||
@@ -5,11 +5,14 @@ import com.eactive.apim.portal.common.converter.EnabledStatusConverter;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.boot.web.servlet.ServletComponentScan;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.web.PageableHandlerMethodArgumentResolver;
|
||||
import org.springframework.format.FormatterRegistry;
|
||||
@@ -18,10 +21,13 @@ import org.springframework.web.filter.CharacterEncodingFilter;
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||
import org.springframework.web.multipart.support.MultipartFilter;
|
||||
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceChainRegistration;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||
import org.springframework.web.servlet.resource.ResourceUrlEncodingFilter;
|
||||
import org.springframework.web.servlet.resource.VersionResourceResolver;
|
||||
import org.springframework.web.servlet.view.json.MappingJackson2JsonView;
|
||||
|
||||
|
||||
@@ -32,6 +38,22 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
|
||||
public static final String ERROR = "error";
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
// 정적자원 해시 버전닝 토글(application.yml: app.resource-versioning.enabled).
|
||||
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceVersioningEnabled 참고).
|
||||
@Value("${app.resource-versioning.enabled:true}")
|
||||
private boolean resourceVersioningEnabled;
|
||||
|
||||
// 정적자원 서버측 캐싱(ResourceChain 캐시) 토글(application.yml: app.resource-caching.enabled).
|
||||
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceCachingEnabled 참고).
|
||||
// 개발환경에서 OFF 면 sass/JS 변경이 서버 재시작 없이 즉시 반영된다.
|
||||
@Value("${app.resource-caching.enabled:false}")
|
||||
private boolean resourceCachingEnabled;
|
||||
|
||||
public PortalConfigWebDispatcherServlet(Environment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
|
||||
@Bean
|
||||
@@ -61,16 +83,64 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
addStaticResourceHandler(registry, "/css/**", "/css/", "classpath:/static/css/");
|
||||
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", "classpath:/static/webfonts/");
|
||||
addStaticResourceHandler(registry, "/font/**", "/font/", "classpath:/static/font/");
|
||||
addStaticResourceHandler(registry, "/html/**", "/html/", "classpath:/static/html/");
|
||||
addStaticResourceHandler(registry, "/images/**", "/images/", "classpath:/static/images/");
|
||||
addStaticResourceHandler(registry, "/img/**", "/img/", "classpath:/static/img/");
|
||||
addStaticResourceHandler(registry, "/js/**", "/js/", "classpath:/static/js/");
|
||||
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", "classpath:/static/plugins/");
|
||||
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", "classpath:/static/favicon.ico");
|
||||
}
|
||||
|
||||
registry.addResourceHandler("/css/**").addResourceLocations("/css/", "classpath:/static/css/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/webfonts/**").addResourceLocations("/webfonts/", "classpath:/static/webfonts/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/font/**").addResourceLocations("/font/", "classpath:/static/font/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/html/**").addResourceLocations("/html/", "classpath:/static/html/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/images/**").addResourceLocations("/images/", "classpath:/static/images/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/img/**").addResourceLocations("/img/", "classpath:/static/img/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/js/**").addResourceLocations("/js/", "classpath:/static/js/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/plugins/**").addResourceLocations("/plugins/", "classpath:/static/plugins/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/favicon.ico").addResourceLocations("/favicon.ico", "classpath:/static/favicon.ico").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
/**
|
||||
* 정적자원 핸들러 공통 등록.
|
||||
*
|
||||
* <p>콘텐츠 해시 버전닝이 켜져 있으면 {@link VersionResourceResolver} 를 체인 앞단에 추가하여
|
||||
* {@code /css/main.css} 요청을 내용 해시가 포함된 {@code /css/main-<hash>.css} 로 매핑한다.
|
||||
* 내용이 바뀌면 URL 이 바뀌므로 강제 새로고침 없이 브라우저 캐시가 무효화된다. Thymeleaf 의
|
||||
* {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 가 해시 URL 로 치환한다.</p>
|
||||
*/
|
||||
private void addStaticResourceHandler(ResourceHandlerRegistry registry, String pattern, String... locations) {
|
||||
// 캐싱 ON(prod) 이면 브라우저에 1일 캐시를, OFF(dev/local) 면 no-store 를 내려보낸다.
|
||||
// no-store 는 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다.
|
||||
CacheControl cacheControl = isResourceCachingEnabled()
|
||||
? CacheControl.maxAge(1, TimeUnit.DAYS)
|
||||
: CacheControl.noStore();
|
||||
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
|
||||
.addResourceLocations(locations)
|
||||
.setCacheControl(cacheControl)
|
||||
.resourceChain(isResourceCachingEnabled());
|
||||
if (isResourceVersioningEnabled()) {
|
||||
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
|
||||
}
|
||||
chain.addResolver(new PathResourceResolver());
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적자원 해시 버전닝 활성 여부. prod 프로파일은 토글과 무관하게 항상 ON,
|
||||
* 그 외 프로파일은 {@code app.resource-versioning.enabled} 값을 따른다.
|
||||
*/
|
||||
private boolean isResourceVersioningEnabled() {
|
||||
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||
return true;
|
||||
}
|
||||
return resourceVersioningEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적자원 서버측 캐싱(ResourceChain 의 CachingResourceResolver) 활성 여부.
|
||||
* prod 프로파일은 토글과 무관하게 항상 ON, 그 외 프로파일은 {@code app.resource-caching.enabled} 값을 따른다.
|
||||
*
|
||||
* <p>OFF 면 매 요청마다 리소스를 재해석하므로, sass/JS 변경이 서버 재시작 없이 즉시 반영된다.
|
||||
* ({@code spring.web.resources.cache} 는 브라우저 캐시 헤더만 제어할 뿐 이 서버측 캐시와는 무관하다.)</p>
|
||||
*/
|
||||
private boolean isResourceCachingEnabled() {
|
||||
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||
return true;
|
||||
}
|
||||
return resourceCachingEnabled;
|
||||
}
|
||||
|
||||
|
||||
@@ -123,4 +193,13 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
registrationBean.addUrlPatterns("/*");
|
||||
return registrationBean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thymeleaf {@code @{...}} 링크를 해시 버전 URL 로 치환하기 위한 필터.
|
||||
* {@code @EnableWebMvc} 환경에서는 자동 등록되지 않으므로 명시적으로 빈 등록한다.
|
||||
*/
|
||||
@Bean
|
||||
public ResourceUrlEncodingFilter resourceUrlEncodingFilter() {
|
||||
return new ResourceUrlEncodingFilter();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.web.authentication.logout.LogoutHandler;
|
||||
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -19,10 +20,15 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.Enumeration;
|
||||
|
||||
/**
|
||||
* 로그아웃 성공 시 세션 정보를 로깅하는 핸들러
|
||||
* 로그아웃 처리 핸들러.
|
||||
*
|
||||
* <p>DB 세션 레코드 정리와 로깅은 {@link LogoutHandler#logout}에서 수행한다. 이 시점은 기본
|
||||
* {@code SecurityContextLogoutHandler}가 HTTP 세션을 무효화하기 <b>전</b>이라 세션이 유효하다.
|
||||
* (성공 핸들러 {@link #onLogoutSuccess}는 무효화 <b>후</b>에 실행되어 {@code getSession(false)}가
|
||||
* null 이 되므로, 거기서 정리하면 DB 행이 남아 재로그인 시 "이미 접속중" 오탐이 발생한다.)
|
||||
*/
|
||||
@Component
|
||||
public class PortalLogoutSuccessHandler implements LogoutSuccessHandler {
|
||||
public class PortalLogoutSuccessHandler implements LogoutHandler, LogoutSuccessHandler {
|
||||
|
||||
private static final Logger sessionLogger = LoggerFactory.getLogger("eapim.portal.session");
|
||||
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
|
||||
@@ -33,9 +39,12 @@ public class PortalLogoutSuccessHandler implements LogoutSuccessHandler {
|
||||
this.userSessionService = userSessionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 무효화 전에 실행되어 DB 세션 레코드를 정리하고 로그아웃 정보를 로깅한다.
|
||||
*/
|
||||
@Override
|
||||
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||
Authentication authentication) throws IOException, ServletException {
|
||||
public void logout(HttpServletRequest request, HttpServletResponse response,
|
||||
Authentication authentication) {
|
||||
HttpSession session = request.getSession(false);
|
||||
|
||||
if (session != null) {
|
||||
@@ -107,7 +116,14 @@ public class PortalLogoutSuccessHandler implements LogoutSuccessHandler {
|
||||
|
||||
sessionLogger.info(logMessage.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃(세션 무효화) 완료 후 메인으로 리다이렉트한다.
|
||||
*/
|
||||
@Override
|
||||
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||
Authentication authentication) throws IOException, ServletException {
|
||||
response.sendRedirect(request.getContextPath() + "/");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.boot.system.ApplicationPid;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Prints a startup banner to stdout once boot is complete.
|
||||
*
|
||||
* <p>Uses {@link System#out} directly (not a logger) so the info is shown even when
|
||||
* the logback console appender is disabled (CONSOLE_LOG_ENABLED=false).
|
||||
*/
|
||||
@Component
|
||||
public class StartupInfoPrinter implements ApplicationListener<ApplicationReadyEvent> {
|
||||
|
||||
@Override
|
||||
public void onApplicationEvent(ApplicationReadyEvent event) {
|
||||
Environment env = event.getApplicationContext().getEnvironment();
|
||||
|
||||
String port = env.getProperty("server.port", "8080");
|
||||
String contextPath = env.getProperty("server.servlet.context-path", "/");
|
||||
if (!contextPath.startsWith("/")) {
|
||||
contextPath = "/" + contextPath;
|
||||
}
|
||||
String url = "http://localhost:" + port + contextPath;
|
||||
|
||||
String[] active = env.getActiveProfiles();
|
||||
String profile = active.length == 0
|
||||
? String.join(", ", env.getDefaultProfiles())
|
||||
: String.join(", ", active);
|
||||
|
||||
String instanceName = env.getProperty("inst.Name", System.getProperty("inst.Name", "unknown"));
|
||||
String pid = new ApplicationPid().toString();
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(System.lineSeparator());
|
||||
sb.append("=====================================================").append(System.lineSeparator());
|
||||
sb.append(" EAPIM Portal - startup complete").append(System.lineSeparator());
|
||||
sb.append("=====================================================").append(System.lineSeparator());
|
||||
sb.append(" URL : ").append(url).append(System.lineSeparator());
|
||||
sb.append(" Spring profile : ").append(profile).append(System.lineSeparator());
|
||||
sb.append(" Instance name : ").append(instanceName).append(System.lineSeparator());
|
||||
sb.append(" PID : ").append(pid).append(System.lineSeparator());
|
||||
sb.append("=====================================================").append(System.lineSeparator());
|
||||
|
||||
System.out.println(sb);
|
||||
System.out.flush();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -20,7 +20,7 @@ import java.util.Map;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InquiryAdminNotifier {
|
||||
public class CommunityAdminNotifier {
|
||||
|
||||
private final UserInfoRepository userInfoRepository;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
+1
-1
@@ -42,7 +42,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
||||
private final InquiryCommentService commentService;
|
||||
private final InquiryCommentRepository commentRepository;
|
||||
private final InquiryCommentPermissionChecker permissionChecker;
|
||||
private final InquiryAdminNotifier adminNotifier;
|
||||
private final CommunityAdminNotifier adminNotifier;
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final UserInfoRepository userInfoRepository;
|
||||
|
||||
|
||||
+37
@@ -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<Map<String, Object>> handleUnsupportedAuthType(DjbUnsupportedAuthTypeException ex) {
|
||||
Map<String, Object> 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<Map<String, Object>> handleAccessDenied(AccessDeniedException ex) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("code", "FORBIDDEN");
|
||||
body.put("message", ex.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
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} 를 직접 사용한다(사용자 지시).
|
||||
*
|
||||
* <p>{@code getOrCreateProperty} 는 DB row 미존재 시 default 로 INSERT(그룹 존재 시)하지만
|
||||
* <em>공백 값 보정은 하지 않으므로</em>, 여기 {@link #resolve} 에서 {@code isBlank → default} 폴백을 둔다.
|
||||
*
|
||||
* <p>비-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 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 DEFAULT_BASE_URL = "PortalMock";
|
||||
public static final String DEFAULT_TIMEOUT_SEC = "10";
|
||||
public static final String DEFAULT_USE_PROXY = "true";
|
||||
public static final String DEFAULT_TOKEN_USE_PROXY = "true";
|
||||
private static final int TIMEOUT_FALLBACK_MS = 10_000;
|
||||
/** 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 토큰)");
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트베드 API 호출 시 서버 프록시({@code /api/call-api}) 사용 여부.
|
||||
* {@code false} 이면 브라우저에서 대상 주소로 직접 호출(CORS 허용 필요). 기본 true.
|
||||
*/
|
||||
public boolean useProxy() {
|
||||
return parseBool(resolve(KEY_USE_PROXY, DEFAULT_USE_PROXY,
|
||||
"테스트베드 API 호출 시 서버 프록시(/api/call-api) 사용 여부(true/false). false 이면 브라우저에서 직접 호출."), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 발급 시 서버 프록시({@code /api/call-api}) 사용 여부. API 호출 프록시와 별개로 설정.
|
||||
* {@code false} 이면 브라우저에서 토큰 URL 로 직접 호출(CORS 허용 필요). 기본 true.
|
||||
* (mock 모드는 포탈 자체 mock 토큰이라 프록시 유지가 자연스럽고, 실 GW 는 직접호출로 뺄 수 있음.)
|
||||
*/
|
||||
public boolean tokenUseProxy() {
|
||||
return parseBool(resolve(KEY_TOKEN_USE_PROXY, DEFAULT_TOKEN_USE_PROXY,
|
||||
"테스트베드 토큰 발급 시 서버 프록시(/api/call-api) 사용 여부(true/false). false 이면 브라우저에서 직접 호출."), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로퍼티 값을 boolean 으로 해석. 레거시 {@code Y/N} 값도 자동 변환한다.
|
||||
* {@code true}/{@code Y} → true, {@code false}/{@code N} → false, 그 외/null → {@code def}.
|
||||
*/
|
||||
private static boolean parseBool(String v, boolean def) {
|
||||
if (v == null) {
|
||||
return def;
|
||||
}
|
||||
String s = v.trim();
|
||||
if (s.equalsIgnoreCase("true") || s.equalsIgnoreCase("Y")) {
|
||||
return true;
|
||||
}
|
||||
if (s.equalsIgnoreCase("false") || s.equalsIgnoreCase("N")) {
|
||||
return false;
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
/** 테스트베드 프록시 연결/응답 타임아웃(ms). 미설정/파싱 실패 시 10초. (단위 저장: 초) */
|
||||
public int timeoutMillis() {
|
||||
try {
|
||||
int sec = Integer.parseInt(resolve(KEY_TIMEOUT_SEC, DEFAULT_TIMEOUT_SEC,
|
||||
"테스트베드 프록시 연결/응답 타임아웃(초)"));
|
||||
return sec > 0 ? sec * 1000 : TIMEOUT_FALLBACK_MS;
|
||||
} catch (Exception e) {
|
||||
return TIMEOUT_FALLBACK_MS;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* GW 응답유형(spec responseType=gw)에서 사용할 API 호출 base URL.
|
||||
* PortalMock → 요청 포탈 origin(mock 필터가 프록시), GATEWAY → {@code baseUrl}.
|
||||
* (별도 {@code swagger.gw.address} 프로퍼티를 두지 않고 이 값을 단일 기준으로 사용한다.)
|
||||
*/
|
||||
public String resolveApiBaseUrl(String portalOrigin) {
|
||||
if (resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) {
|
||||
return stripTrailingSlash(portalOrigin);
|
||||
}
|
||||
return stripTrailingSlash(baseUrl());
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+51
@@ -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<DjbTestbedContextDto> context(HttpServletRequest request) {
|
||||
return ResponseEntity.ok(authService.buildContext(resolveOrigin(request)));
|
||||
}
|
||||
|
||||
@GetMapping("/credentials/{clientId}/secret")
|
||||
public ResponseEntity<DjbCredentialSecretDto> 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);
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
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 com.eactive.apim.portal.djb.testbed.service.DjbTestbedSpecServerRewriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Optional;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
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/yaml)에 AUTHTYPE 기반 securityScheme 를 주입하고,
|
||||
* 서버 sentinel({@link DjbTestbedSpecServerRewriter#SERVER_SENTINEL})을 API SPEC 설정(responseType)에
|
||||
* 따른 실주소로 치환해 반환한다.
|
||||
* {@code default-token-api-spec} 은 클래스패스 기본 spec 을 그대로 반환(auth enrich 대상 외).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/djb/testbed/apis")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DjbTestbedSpecController {
|
||||
|
||||
private final ApiSpecInfoService apiSpecInfoService;
|
||||
private final DjbTestbedAuthService authService;
|
||||
private final DjbSwaggerSpecEnricher enricher;
|
||||
private final DjbTestbedSpecServerRewriter serverRewriter;
|
||||
|
||||
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<String> swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
|
||||
String json = buildSpecJson(id, request);
|
||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
|
||||
public ResponseEntity<String> swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
|
||||
String json = buildSpecJson(id, request);
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
|
||||
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
|
||||
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
DjbAuthType authType = authService.resolveAuthType(id);
|
||||
String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType);
|
||||
return serverRewriter.rewriteServer(enriched, spec.get(), request);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<DjbCredentialOptionDto> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.eactive.apim.portal.djb.testbed.enums;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.exception.DjbUnsupportedAuthTypeException;
|
||||
|
||||
/**
|
||||
* 테스트베드가 지원하는 인증 체계. {@code TSEAIHE01.AUTHTYPE}(소문자 저장) 값을 매핑한다.
|
||||
* <ul>
|
||||
* <li>{@code oauth}/{@code oauth2}/{@code ca} → {@link #OAUTH}</li>
|
||||
* <li>{@code api_key}/{@code apikey} → {@link #API_KEY}</li>
|
||||
* <li>{@code none}/{@code no}/{@code noauth} → {@link #NONE}(인증 없이 호출 — 앱 선택 불필요)</li>
|
||||
* <li>{@code null}(EAIMessage 미존재) 및 그 외 → {@link DjbUnsupportedAuthTypeException}</li>
|
||||
* </ul>
|
||||
* ({@code ca} 는 게이트웨이 레거시 매퍼 {@code ObpApiService.mapAuthType} 이 OAUTH2 로 취급하는 값.)
|
||||
*/
|
||||
public enum DjbAuthType {
|
||||
OAUTH,
|
||||
API_KEY,
|
||||
NONE;
|
||||
|
||||
public static DjbAuthType fromAuthtype(String authtype) {
|
||||
// null(EAIMessage 미존재)은 데이터 문제 → 종전대로 예외. "none" 은 인증 없는 API.
|
||||
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;
|
||||
case "none":
|
||||
case "no":
|
||||
case "noauth":
|
||||
return NONE;
|
||||
default:
|
||||
throw new DjbUnsupportedAuthTypeException(authtype);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.eactive.apim.portal.djb.testbed.enums;
|
||||
|
||||
/**
|
||||
* 테스트베드 호출이 향하는 대상 모드.
|
||||
* <ul>
|
||||
* <li>{@code PORTAL_MOCK} — {@code djb.gateway.base-url} 이 문자열 "PortalMock" 인 경우.
|
||||
* {@code ApiTesterFilter} 가 mock 토큰/샘플 응답을 반환한다.</li>
|
||||
* <li>{@code GATEWAY} — 그 외(실제 게이트웨이 URL). 토큰 발급 요청을 실 게이트웨이로 forward.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum DjbGatewayMode {
|
||||
PORTAL_MOCK,
|
||||
GATEWAY
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
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)에 인증 체계를 주입한다.
|
||||
*
|
||||
* <p>현재 데이터는 <b>OpenAPI 3.0</b>(auto-gen {@code "openapi":"3.0.0"})이므로
|
||||
* {@code components.securitySchemes} + 글로벌 {@code security} 에 주입한다.
|
||||
* (원본이 Swagger 2.0 이면 {@code securityDefinitions} 로 폴백.)
|
||||
*
|
||||
* <p>게이트웨이가 OAuth 액세스 토큰을 표준 {@code Authorization} 이 아니라 커스텀 헤더
|
||||
* {@code X-AUTH-TOKEN: Bearer ...} 로 받으므로(DJERP_4000 v0.2), OAuth 도 API_KEY 와 동일하게
|
||||
* <b>apiKey-in-header</b> 스킴으로 모델링한다(헤더명만 상이). 토큰 발급은 템플릿 JS가
|
||||
* {@code /api/call-api} 경유로 수행해 값을 {@code "Bearer <token>"} 으로 authorize 한다.
|
||||
*
|
||||
* <p>원본의 {@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;
|
||||
|
||||
// NONE: 인증 없이 호출 — 글로벌 security / securityScheme 흔적을 제거해
|
||||
// Swagger UI 의 'Authorize' 버튼이 뜨지 않게 한다. (저장 spec 에 빈 securitySchemes 가
|
||||
// 남아 있으면 UI 가 빈 인증 모달 버튼을 렌더하므로 정리한다.)
|
||||
if (authType == DjbAuthType.NONE) {
|
||||
return stripSecurity(root);
|
||||
}
|
||||
|
||||
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":"<header>" }} — 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;
|
||||
}
|
||||
|
||||
/** NONE 용: 글로벌 {@code security} / {@code components.securitySchemes} / {@code securityDefinitions} 제거. */
|
||||
private String stripSecurity(ObjectNode root) throws JsonProcessingException {
|
||||
root.remove("security");
|
||||
root.remove("securityDefinitions"); // swagger 2.0
|
||||
JsonNode components = root.get("components");
|
||||
if (components != null && components.isObject()) {
|
||||
((ObjectNode) components).remove("securitySchemes");
|
||||
}
|
||||
return objectMapper.writeValueAsString(root);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<DjbCredentialOptionDto> 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();
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package com.eactive.apim.portal.djb.testbed.service;
|
||||
|
||||
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.yaml.snakeyaml.DumperOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
/**
|
||||
* testbed spec(JSON)에 baking 된 서버 sentinel({@value #SERVER_SENTINEL})을
|
||||
* API SPEC 설정(responseType)에 따른 실주소로 치환한다.
|
||||
*
|
||||
* <ul>
|
||||
* <li>gw : {@link DjbTestbedGatewayProperty#resolveApiBaseUrl}(GW base URL, 단일 기준)</li>
|
||||
* <li>mock : {@code ApiSpecInfo.mockUrl}</li>
|
||||
* <li>sample(기본) : 요청 포탈 origin(scheme://host)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>spec 의 서버주소는 Swagger UI path 표시 + cURL 스니펫용이다(실호출은 {@code /api/call-api} 프록시).
|
||||
* admin(app.js buildOpenApiSpec)은 env별 실주소를 저장시 굳히지 않고 sentinel 만 path 앞에 baking 하며,
|
||||
* 실제 치환은 이 서비스가 spec 제공 시점에 수행한다. 또한 동일 spec 을 YAML 로 변환 제공한다.
|
||||
*
|
||||
* <p>sentinel 문자열은 유일하므로 문자열 치환으로 처리한다(없으면 원본 유지, 멱등).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DjbTestbedSpecServerRewriter {
|
||||
|
||||
/** admin(app.js buildOpenApiSpec)이 path 앞에 baking 하는 고정 서버 sentinel. */
|
||||
public static final String SERVER_SENTINEL = "http://swagger-server-url";
|
||||
|
||||
private final DjbTestbedGatewayProperty gatewayProperty;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/** sentinel → 응답유형별 실주소 치환한 spec JSON 반환. sentinel 없거나 실주소 미확정 시 원본 유지. */
|
||||
public String rewriteServer(String specJson, ApiSpecInfo spec, HttpServletRequest request) {
|
||||
if (specJson == null || !specJson.contains(SERVER_SENTINEL)) {
|
||||
return specJson;
|
||||
}
|
||||
String base = stripTrailingSlash(resolveBase(spec, request));
|
||||
if (!StringUtils.hasText(base)) {
|
||||
return specJson; // 실주소 미확정 시 sentinel 유지
|
||||
}
|
||||
return specJson.replace(SERVER_SENTINEL, base);
|
||||
}
|
||||
|
||||
/** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */
|
||||
public String toYaml(String specJson) {
|
||||
try {
|
||||
Object tree = objectMapper.readValue(specJson, Object.class);
|
||||
DumperOptions opts = new DumperOptions();
|
||||
opts.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
|
||||
opts.setPrettyFlow(true);
|
||||
return new Yaml(opts).dump(tree);
|
||||
} catch (Exception e) {
|
||||
log.error("spec YAML 변환 실패, JSON 원본 반환", e);
|
||||
return specJson;
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveBase(ApiSpecInfo spec, HttpServletRequest request) {
|
||||
String rt = (spec == null || !StringUtils.hasText(spec.getResponseType()))
|
||||
? "sample" : spec.getResponseType().trim();
|
||||
if ("gw".equalsIgnoreCase(rt)) {
|
||||
return gatewayProperty.resolveApiBaseUrl(originOf(request));
|
||||
}
|
||||
if ("mock".equalsIgnoreCase(rt)) {
|
||||
String mock = (spec == null) ? null : spec.getMockUrl();
|
||||
return StringUtils.hasText(mock) ? mock.trim() : originOf(request);
|
||||
}
|
||||
// sample(기본): 포탈 origin
|
||||
return originOf(request);
|
||||
}
|
||||
|
||||
/** 요청 기준 포탈 origin(scheme://host[:port]) — 리버스 프록시 X-Forwarded-* 우선. */
|
||||
public static String originOf(HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
return "";
|
||||
}
|
||||
String scheme = firstNonBlank(request.getHeader("X-Forwarded-Proto"), request.getScheme());
|
||||
String host = firstNonBlank(request.getHeader("X-Forwarded-Host"), request.getHeader("Host"));
|
||||
if (StringUtils.hasText(host)) {
|
||||
return scheme + "://" + host.trim();
|
||||
}
|
||||
int port = request.getServerPort();
|
||||
String hostPort = request.getServerName() + ((port == 80 || port == 443) ? "" : ":" + port);
|
||||
return scheme + "://" + hostPort;
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String a, String b) {
|
||||
return StringUtils.hasText(a) ? a.trim() : b;
|
||||
}
|
||||
|
||||
private static String stripTrailingSlash(String s) {
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
String t = s.trim();
|
||||
while (t.endsWith("/")) {
|
||||
t = t.substring(0, t.length() - 1);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
spring:
|
||||
jta:
|
||||
enabled: true
|
||||
enabled: false
|
||||
config:
|
||||
activate:
|
||||
on-profile: dev
|
||||
@@ -39,7 +39,7 @@ gateway:
|
||||
|
||||
|
||||
portal:
|
||||
auth-virtual-code: 654321
|
||||
# auth-virtual-code: 654321
|
||||
test-auth-notice-enabled: true
|
||||
dev:
|
||||
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
spring:
|
||||
jta:
|
||||
enabled: true
|
||||
enabled: false
|
||||
config:
|
||||
activate:
|
||||
on-profile: prod
|
||||
@@ -11,6 +11,9 @@ spring:
|
||||
cachecontrol:
|
||||
max-age: 86400
|
||||
public: true
|
||||
# 정적자원 콘텐츠 해시 버전닝은 PortalConfigWebDispatcherServlet 가 prod 프로파일에서
|
||||
# 항상 ON 으로 수행한다(app.resource-versioning.enabled 토글 무시).
|
||||
# 해시 URL + 장기 캐시(max-age 86400)로 배포 시 자동 캐시 무효화.
|
||||
jpa:
|
||||
properties:
|
||||
hibernate:
|
||||
@@ -26,4 +29,10 @@ spring:
|
||||
cache: false
|
||||
|
||||
portal:
|
||||
test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
|
||||
test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
|
||||
|
||||
app:
|
||||
resource-versioning:
|
||||
enabled: true
|
||||
resource-caching:
|
||||
enabled: true
|
||||
@@ -1,6 +1,6 @@
|
||||
spring:
|
||||
jta:
|
||||
enabled: true
|
||||
enabled: false
|
||||
web:
|
||||
resources:
|
||||
cache:
|
||||
|
||||
@@ -2,7 +2,9 @@ server:
|
||||
servlet:
|
||||
context-path: /
|
||||
session:
|
||||
timeout: 15m
|
||||
timeout: 10m
|
||||
cookie:
|
||||
name: PORTAL_JSESSIONID
|
||||
encoding:
|
||||
charset: UTF-8
|
||||
port: '39130'
|
||||
@@ -33,12 +35,17 @@ spring:
|
||||
max-age: 0
|
||||
must-revalidate: true
|
||||
no-cache: true
|
||||
# 정적자원 콘텐츠 해시 버전닝(/css/main.css -> /css/main-<hash>.css)은
|
||||
# @EnableWebMvc 로 WebMvcAutoConfiguration 이 비활성화되어 spring.web.resources.chain
|
||||
# 설정으로는 적용되지 않는다. 실제 버전닝은 PortalConfigWebDispatcherServlet 가
|
||||
# 아래 app.resource-versioning.enabled 토글을 읽어 직접 수행한다.
|
||||
|
||||
jta:
|
||||
enabled: true
|
||||
enabled: false
|
||||
atomikos:
|
||||
properties:
|
||||
log-base-dir: /logs/atomikos
|
||||
# log-base-dir: /logs/eapim/${inst.Name:devSvr11}/atomikos
|
||||
log-base-dir: /dev/null
|
||||
log-base-name: adp_tx
|
||||
|
||||
thymeleaf:
|
||||
@@ -49,6 +56,19 @@ spring:
|
||||
cache: false
|
||||
main:
|
||||
allow-bean-definition-overriding: true
|
||||
|
||||
# 정적자원 해시 버전닝 토글 (prod 제외 전 프로파일에 적용).
|
||||
# false 로 변경 시 /css/main.css 가 해시 없이 그대로 서빙되어 sourceMap 디버깅 가능.
|
||||
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
|
||||
app:
|
||||
resource-versioning:
|
||||
enabled: false
|
||||
# 정적자원 서버측 캐싱(ResourceChain 캐시) 토글 (prod 제외 전 프로파일에 적용).
|
||||
# false 면 sass/JS 변경이 서버 재시작 없이 즉시 반영됨(매 요청 재해석).
|
||||
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
|
||||
resource-caching:
|
||||
enabled: false
|
||||
|
||||
security:
|
||||
basic:
|
||||
enabled: false
|
||||
@@ -235,12 +255,18 @@ page:
|
||||
corp:
|
||||
name: "법인회원가입"
|
||||
path: "/signup/portalOrg"
|
||||
verification_email:
|
||||
name: "이메일 인증"
|
||||
path: "/signup/verification-email"
|
||||
decision:
|
||||
name: "법인회원 초대"
|
||||
path: "/signup/decision_process"
|
||||
agreements:
|
||||
name: 약관
|
||||
path: "#"
|
||||
children:
|
||||
terms:
|
||||
name: "이용약관 및 개인정보처리방침"
|
||||
name: "이용약관"
|
||||
path: "/agreements/terms"
|
||||
about:
|
||||
name: 안내
|
||||
@@ -310,6 +336,9 @@ page:
|
||||
mypage:
|
||||
name: "내 정보 관리"
|
||||
path: "/mypage"
|
||||
verification_email:
|
||||
name: "이메일 인증"
|
||||
path: "/mypage/verification-email"
|
||||
user_list:
|
||||
name: "이용자 관리"
|
||||
path: "/users"
|
||||
|
||||
@@ -5,13 +5,20 @@
|
||||
|
||||
<property name="LOG_PATH" value="${profileLogPath}/${inst.Name:-devSvr00}"/>
|
||||
|
||||
<!-- <springProfile name="gf63">-->
|
||||
<!-- <property name="LOG_PATH" value="d:/kjb-logs/${inst.Name:-devSvr00}"/>-->
|
||||
<!-- </springProfile>-->
|
||||
|
||||
<!-- <property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger{5} - %msg %n" />-->
|
||||
<property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger - %msg %n" />
|
||||
|
||||
<!-- 콘솔 로그 JVM 파라미터 제어 -->
|
||||
<!-- 사용: -DCONSOLE_LOG_ENABLED=true -DCONSOLE_LOG_LEVEL=DEBUG -->
|
||||
<!-- 기본값: 비활성(콘솔 OFF), 활성 시 레벨 INFO -->
|
||||
<property name="CONSOLE_LOG_ENABLED" value="${CONSOLE_LOG_ENABLED:-false}"/>
|
||||
<property name="CONSOLE_LOG_LEVEL" value="${CONSOLE_LOG_LEVEL:-INFO}"/>
|
||||
<!-- enabled=true → 지정 레벨, false → OFF (대소문자 모두 대응) -->
|
||||
<property name="__consoleLevel.true" value="${CONSOLE_LOG_LEVEL}"/>
|
||||
<property name="__consoleLevel.TRUE" value="${CONSOLE_LOG_LEVEL}"/>
|
||||
<property name="__consoleLevel.false" value="OFF"/>
|
||||
<property name="__consoleLevel.FALSE" value="OFF"/>
|
||||
<property name="CONSOLE_EFFECTIVE_LEVEL" value="${__consoleLevel.${CONSOLE_LOG_ENABLED}}"/>
|
||||
|
||||
|
||||
<appender name="HIBERNATE_APPENDER" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/hibernate.log</file>
|
||||
@@ -50,6 +57,9 @@
|
||||
</appender>
|
||||
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||
<level>${CONSOLE_EFFECTIVE_LEVEL}</level>
|
||||
</filter>
|
||||
<encoder><Pattern>${CONSOLE_PATTERN}</Pattern></encoder>
|
||||
</appender>
|
||||
|
||||
@@ -61,6 +71,7 @@
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
|
||||
<springProfile name="dev">
|
||||
@@ -69,15 +80,4 @@
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
|
||||
<springProfile name="stage">
|
||||
<root level="INFO">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
|
||||
|
||||
<springProfile name="stage,dev">
|
||||
|
||||
</springProfile>
|
||||
</configuration>
|
||||
@@ -5739,11 +5739,11 @@ select.form-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 16px;
|
||||
height: 40px;
|
||||
padding: 0 8px;
|
||||
height: 30px;
|
||||
white-space: nowrap;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: #000000;
|
||||
border: none;
|
||||
@@ -5776,6 +5776,13 @@ select.form-control {
|
||||
.list-table-btn--secondary:hover {
|
||||
background-color: rgb(187.8846153846, 197.2807692308, 234.8653846154);
|
||||
}
|
||||
.list-table-btn--danger {
|
||||
background-color: #F8D7DA;
|
||||
color: #B02A37;
|
||||
}
|
||||
.list-table-btn--danger:hover {
|
||||
background-color: rgb(244.5521276596, 195.2978723404, 199.7755319149);
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
display: flex;
|
||||
@@ -9444,6 +9451,117 @@ button.djb-comment-submit:disabled {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.testbed-app-panel {
|
||||
margin: 8px 0 24px;
|
||||
padding: 24px;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.testbed-app-panel .testbed-app-panel__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.testbed-app-panel .testbed-app-panel__title {
|
||||
position: relative;
|
||||
padding-left: 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1A1A2E;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.testbed-app-panel .testbed-app-panel__title::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 15px;
|
||||
background: #0049b4;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.testbed-app-panel .testbed-app-panel__desc {
|
||||
font-size: 14px;
|
||||
color: #64748B;
|
||||
}
|
||||
.testbed-app-panel .testbed-app-field {
|
||||
position: relative;
|
||||
max-width: 380px;
|
||||
}
|
||||
.testbed-app-panel .testbed-app-field::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
margin-top: -6px;
|
||||
border-right: 2px solid #64748B;
|
||||
border-bottom: 2px solid #64748B;
|
||||
transform: rotate(45deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
.testbed-app-panel #apps {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
padding: 0 42px 0 16px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
color: #1A1A2E;
|
||||
background: #F8FAFC;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 8px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.testbed-app-panel #apps:hover:not(:disabled) {
|
||||
border-color: #94A3B8;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
.testbed-app-panel #apps:focus {
|
||||
outline: none;
|
||||
background: #FFFFFF;
|
||||
border-color: #0049b4;
|
||||
box-shadow: 0 0 0 3px rgba(0, 73, 180, 0.14);
|
||||
}
|
||||
.testbed-app-panel #apps:disabled {
|
||||
color: #94A3B8;
|
||||
background: #F8FAFC;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.testbed-app-panel .testbed-app-notice {
|
||||
margin: 16px 0 0;
|
||||
padding: 11px 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #64748B;
|
||||
background: #EFF6FF;
|
||||
border: 1px solid rgba(0, 73, 180, 0.18);
|
||||
border-left: 3px solid #0049b4;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.testbed-app-panel .testbed-app-notice::before {
|
||||
content: "ⓘ ";
|
||||
color: #0049b4;
|
||||
font-weight: 700;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.testbed-app-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
.testbed-app-panel .testbed-app-field {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.api-overview-card {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
@@ -10901,7 +11019,7 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.signup-selection-page {
|
||||
min-height: calc(100vh - 140px);
|
||||
min-height: calc(100vh - 380px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -11434,6 +11552,7 @@ button.djb-comment-submit:disabled {
|
||||
|
||||
.ip-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 21px;
|
||||
}
|
||||
.ip-input-row .ip-input {
|
||||
@@ -11461,7 +11580,7 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
|
||||
.btn-add-ip {
|
||||
width: auto;
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
background-color: #3ba4ed;
|
||||
@@ -11473,20 +11592,23 @@ button.djb-comment-submit:disabled {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-add-ip:hover {
|
||||
background-color: #2b94dd;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.btn-add-ip {
|
||||
width: auto;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
height: 38px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.btn-add-ip {
|
||||
width: auto;
|
||||
width: 89px;
|
||||
height: 36px;
|
||||
font-size: 13px;
|
||||
border-radius: 8px;
|
||||
@@ -11507,6 +11629,7 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
.register-form-container .ip-list .ip-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 21px;
|
||||
padding: 0;
|
||||
border-bottom: none;
|
||||
@@ -11535,7 +11658,6 @@ button.djb-comment-submit:disabled {
|
||||
background-color: #FFFFFF;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 20px;
|
||||
color: #1A1A2E;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
@@ -11548,26 +11670,27 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.register-form-container .ip-list .ip-item .btn-remove-ip {
|
||||
width: 158px !important;
|
||||
height: 60px !important;
|
||||
width: 100px !important;
|
||||
height: 40px !important;
|
||||
background-color: #e5e7eb !important;
|
||||
color: #5f666c !important;
|
||||
border: none !important;
|
||||
border-radius: 12px !important;
|
||||
font-size: 20px !important;
|
||||
font-weight: 700;
|
||||
border-radius: 8px !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: center;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.register-form-container .ip-list .ip-item .btn-remove-ip {
|
||||
width: 100% !important;
|
||||
font-size: 16px !important;
|
||||
height: 50px !important;
|
||||
font-size: 14px !important;
|
||||
height: 38px !important;
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
@@ -13667,7 +13790,26 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
.form-actions-center .btn.btn-danger {
|
||||
display: none;
|
||||
}
|
||||
.form-actions-center .btn-submit.app-modify-btn,
|
||||
.form-actions-center .btn.app-modify-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.app-modify-pc-only-message {
|
||||
display: none;
|
||||
margin: 0;
|
||||
padding: 0 20px 24px;
|
||||
color: #64748B;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.app-modify-pc-only-message {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-management-container {
|
||||
padding: 24px 16px;
|
||||
@@ -13824,6 +13966,21 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
}
|
||||
|
||||
.app-list-expect-date {
|
||||
margin-left: auto;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #6e7780;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.app-list-expect-date {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-list-description {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
@@ -13867,6 +14024,25 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.btn-app-create {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.app-create-pc-only-message {
|
||||
display: none;
|
||||
margin: 0;
|
||||
color: #64748B;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.app-create-pc-only-message {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-page {
|
||||
min-height: calc(100vh - 140px);
|
||||
@@ -16644,16 +16820,15 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
|
||||
.user-current-badge {
|
||||
display: inline-flex;
|
||||
height: 40px;
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
padding: 0 8px;
|
||||
background-color: rgba(167, 139, 250, 0.1);
|
||||
color: #A78BFA;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
min-width: 116px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.user-current-badge {
|
||||
@@ -17130,19 +17305,19 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
display: none;
|
||||
}
|
||||
.inquiry-list-container .list-table .list-table-header .header-cell:nth-child(2) {
|
||||
flex: 1 !important;
|
||||
min-width: 100px;
|
||||
flex: 1 1 0 !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
.inquiry-list-container .list-table .list-table-header .header-cell:nth-child(3) {
|
||||
width: 50px !important;
|
||||
min-width: 50px;
|
||||
flex: none;
|
||||
width: 60px !important;
|
||||
min-width: 60px !important;
|
||||
flex: none !important;
|
||||
justify-content: center;
|
||||
}
|
||||
.inquiry-list-container .list-table .list-table-header .header-cell:nth-child(4) {
|
||||
width: 65px !important;
|
||||
min-width: 65px;
|
||||
flex: none;
|
||||
width: 78px !important;
|
||||
min-width: 78px !important;
|
||||
flex: none !important;
|
||||
justify-content: center;
|
||||
}
|
||||
.inquiry-list-container .list-table .list-table-header .header-cell:nth-child(5) {
|
||||
@@ -17152,8 +17327,9 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
display: none;
|
||||
}
|
||||
.inquiry-list-container .list-table .list-table-row .row-cell:nth-child(2), .inquiry-list-container .list-table .list-table-row .row-cell.row-cell--title {
|
||||
flex: 1 !important;
|
||||
min-width: 100px;
|
||||
flex: 1 1 0 !important;
|
||||
min-width: 0 !important;
|
||||
overflow: hidden;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.inquiry-list-container .list-table .list-table-row .row-cell:nth-child(2) .notice-title-link, .inquiry-list-container .list-table .list-table-row .row-cell.row-cell--title .notice-title-link {
|
||||
@@ -17180,16 +17356,19 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
height: 16px;
|
||||
}
|
||||
.inquiry-list-container .list-table .list-table-row .row-cell:nth-child(3) {
|
||||
width: 50px !important;
|
||||
min-width: 50px;
|
||||
flex: none;
|
||||
width: 60px !important;
|
||||
min-width: 60px !important;
|
||||
flex: none !important;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.inquiry-list-container .list-table .list-table-row .row-cell:nth-child(4) {
|
||||
width: 65px !important;
|
||||
min-width: 65px;
|
||||
flex: none;
|
||||
width: 78px !important;
|
||||
min-width: 78px !important;
|
||||
flex: none !important;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -17379,6 +17558,16 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
.dropdown-wrapper .dropdown-menu .dropdown-item + .dropdown-item {
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
.dropdown-wrapper .dropdown-menu .dropdown-item--danger {
|
||||
color: #B02A37;
|
||||
}
|
||||
.dropdown-wrapper .dropdown-menu .dropdown-item--danger:hover {
|
||||
background: #fdeaec;
|
||||
color: #B02A37;
|
||||
}
|
||||
.dropdown-wrapper .dropdown-menu .dropdown-item--danger:active {
|
||||
background: #f8d7da;
|
||||
}
|
||||
.dropdown-wrapper.open .dropdown-toggle {
|
||||
background: rgba(0, 73, 180, 0.1);
|
||||
color: #0049b4;
|
||||
@@ -17802,6 +17991,13 @@ body.commission-print-page .btn-primary:hover {
|
||||
@media (max-width: 768px) {
|
||||
.terms-tabs {
|
||||
padding: 0;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.terms-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.terms-tabs .tab-link {
|
||||
@@ -17823,8 +18019,10 @@ body.commission-print-page .btn-primary:hover {
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.terms-tabs .tab-link {
|
||||
padding: 16px 16px;
|
||||
font-size: 16px;
|
||||
flex: 1 0 auto;
|
||||
white-space: nowrap;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
@@ -17841,6 +18039,13 @@ body.commission-print-page .btn-primary:hover {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.terms-empty {
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
color: #8c959f;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.terms-selector {
|
||||
padding: 24px;
|
||||
background: #F6F9FB;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -14,8 +14,9 @@
|
||||
const counterEl = document.getElementById('djbCommentCharCounter');
|
||||
|
||||
function getCsrfToken() {
|
||||
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : '';
|
||||
// 세션 기반 CSRF: 쿠키 대신 <meta name="_csrf">에서 토큰을 읽는다.
|
||||
const meta = document.querySelector('meta[name="_csrf"]');
|
||||
return meta ? meta.getAttribute('content') : '';
|
||||
}
|
||||
|
||||
function fetchJson(url, options) {
|
||||
@@ -44,7 +45,15 @@
|
||||
function formatDate(value) {
|
||||
if (!value) return '';
|
||||
try {
|
||||
const d = new Date(value);
|
||||
var d;
|
||||
if (Array.isArray(value)) {
|
||||
// Jackson LocalDateTime 직렬화(timestamp 배열): [year, month(1-base), day, hour, minute, second, nano]
|
||||
var y = value[0], mo = value[1] || 1, da = value[2] || 1;
|
||||
var h = value[3] || 0, mi = value[4] || 0, s = value[5] || 0;
|
||||
d = new Date(y, mo - 1, da, h, mi, s);
|
||||
} else {
|
||||
d = new Date(value);
|
||||
}
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const pad = function (n) { return n < 10 ? '0' + n : '' + n; };
|
||||
return d.getFullYear() + '.' + pad(d.getMonth() + 1) + '.' + pad(d.getDate())
|
||||
|
||||
@@ -355,16 +355,11 @@ const customPopups = {
|
||||
$('#emailValidationPopup').hide();
|
||||
});
|
||||
},
|
||||
showEmailResendPopup: function (message, loginId) {
|
||||
$('#emailResendMessage').html(customPopups._sanitizeHtml(message));
|
||||
$('#userLoginId').val(loginId);
|
||||
$('#emailResend').show();
|
||||
},
|
||||
|
||||
/**
|
||||
* 사용자 초대 팝업 표시
|
||||
* @param {Object} options - 팝업 옵션
|
||||
* @param {Function} options.onConfirm - 확인 버튼 클릭 시 호출되는 콜백 (파라미터: email)
|
||||
* @param {Function} options.onConfirm - 확인 버튼 클릭 시 호출되는 콜백 (파라미터: mobile, notifyConsent)
|
||||
* @param {Function} options.onCancel - 취소 버튼 클릭 시 호출되는 콜백 (선택사항)
|
||||
*/
|
||||
showUserInvite: function (options) {
|
||||
@@ -374,7 +369,8 @@ const customPopups = {
|
||||
const onCancel = options.onCancel;
|
||||
|
||||
// 입력 필드 및 에러 초기화
|
||||
$('#userInviteEmailInput').val('').removeClass('error');
|
||||
$('#userInviteMobileInput').val('').removeClass('error');
|
||||
$('#userInviteNotifyConsent').prop('checked', false);
|
||||
$('#userInvitePopupError').removeClass('show').text('');
|
||||
|
||||
// 팝업 표시 (modal 구조 사용)
|
||||
@@ -389,28 +385,29 @@ const customPopups = {
|
||||
|
||||
// 입력 필드에 포커스
|
||||
setTimeout(function() {
|
||||
$('#userInviteEmailInput').focus();
|
||||
$('#userInviteMobileInput').focus();
|
||||
}, 350);
|
||||
|
||||
// 확인 버튼 이벤트 (기존 이벤트 제거 후 재등록)
|
||||
$('#userInvitePopupConfirmButton').off('click').on('click', function () {
|
||||
const email = $('#userInviteEmailInput').val().trim();
|
||||
const mobile = $('#userInviteMobileInput').val().trim();
|
||||
const notifyConsent = $('#userInviteNotifyConsent').is(':checked');
|
||||
|
||||
// 이메일 유효성 검사
|
||||
if (!email) {
|
||||
customPopups.showUserInviteError('이메일 주소를 입력해주세요.');
|
||||
// 휴대폰 번호 유효성 검사
|
||||
if (!mobile) {
|
||||
customPopups.showUserInviteError('휴대폰 번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 이메일 형식 검증
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailPattern.test(email)) {
|
||||
customPopups.showUserInviteError('올바른 이메일 형식이 아닙니다.');
|
||||
// 휴대폰 번호 형식 검증 (하이픈 유무 모두 허용)
|
||||
const mobilePattern = /^01[016-9]-?\d{3,4}-?\d{4}$/;
|
||||
if (!mobilePattern.test(mobile)) {
|
||||
customPopups.showUserInviteError('올바른 휴대폰 번호 형식이 아닙니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof onConfirm === 'function') {
|
||||
onConfirm(email);
|
||||
onConfirm(mobile, notifyConsent);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -431,14 +428,14 @@ const customPopups = {
|
||||
});
|
||||
|
||||
// Enter 키 이벤트
|
||||
$('#userInviteEmailInput').off('keypress').on('keypress', function (e) {
|
||||
$('#userInviteMobileInput').off('keypress').on('keypress', function (e) {
|
||||
if (e.which === 13) {
|
||||
$('#userInvitePopupConfirmButton').click();
|
||||
}
|
||||
});
|
||||
|
||||
// 입력 시 에러 초기화
|
||||
$('#userInviteEmailInput').off('input').on('input', function () {
|
||||
$('#userInviteMobileInput').off('input').on('input', function () {
|
||||
customPopups.clearUserInviteError();
|
||||
});
|
||||
},
|
||||
@@ -460,14 +457,15 @@ const customPopups = {
|
||||
$('body').css('overflow', '');
|
||||
|
||||
// 입력 필드 초기화
|
||||
$('#userInviteEmailInput').val('').removeClass('error');
|
||||
$('#userInviteMobileInput').val('').removeClass('error');
|
||||
$('#userInviteNotifyConsent').prop('checked', false);
|
||||
$('#userInvitePopupError').removeClass('show').text('');
|
||||
|
||||
// 이벤트 리스너 제거
|
||||
$('#userInvitePopupConfirmButton').off('click');
|
||||
$('#userInvitePopupCancelButton').off('click');
|
||||
$('#userInvitePopupCloseButton').off('click');
|
||||
$('#userInviteEmailInput').off('keypress').off('input');
|
||||
$('#userInviteMobileInput').off('keypress').off('input');
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -476,7 +474,7 @@ const customPopups = {
|
||||
*/
|
||||
showUserInviteError: function (message) {
|
||||
$('#userInvitePopupError').text(message).addClass('show');
|
||||
$('#userInviteEmailInput').addClass('error');
|
||||
$('#userInviteMobileInput').addClass('error');
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -484,7 +482,96 @@ const customPopups = {
|
||||
*/
|
||||
clearUserInviteError: function () {
|
||||
$('#userInvitePopupError').removeClass('show');
|
||||
$('#userInviteEmailInput').removeClass('error');
|
||||
$('#userInviteMobileInput').removeClass('error');
|
||||
},
|
||||
|
||||
/**
|
||||
* 초대취소 팝업 표시 (알림 수신 동의 체크박스 포함)
|
||||
* @param {Object} options - 팝업 옵션
|
||||
* @param {string} options.target - 취소 대상 표시 문자열 (마스킹된 이메일/휴대폰)
|
||||
* @param {Function} options.onConfirm - 확인 버튼 클릭 시 호출되는 콜백 (파라미터: notifyConsent)
|
||||
* @param {Function} options.onCancel - 취소(닫기) 버튼 클릭 시 호출되는 콜백 (선택사항)
|
||||
*/
|
||||
showCancelInvitation: function (options) {
|
||||
options = options || {};
|
||||
|
||||
const target = options.target || '';
|
||||
const onConfirm = options.onConfirm;
|
||||
const onCancel = options.onCancel;
|
||||
|
||||
// 대상 정보 및 체크박스 초기화
|
||||
$('#cancelInvitationTarget').text(target);
|
||||
$('#cancelInvitationNotifyConsent').prop('checked', false);
|
||||
|
||||
// 팝업 표시 (modal 구조 사용)
|
||||
$('#cancelInvitationPopup').show();
|
||||
setTimeout(function() {
|
||||
$('#cancelInvitationModalBackdrop').addClass('show');
|
||||
$('#cancelInvitationModal').addClass('show');
|
||||
}, 10);
|
||||
|
||||
// Body 스크롤 방지
|
||||
$('body').css('overflow', 'hidden');
|
||||
|
||||
// 확인 버튼 이벤트 (기존 이벤트 제거 후 재등록)
|
||||
$('#cancelInvitationPopupConfirmButton').off('click').on('click', function () {
|
||||
const notifyConsent = $('#cancelInvitationNotifyConsent').is(':checked');
|
||||
if (typeof onConfirm === 'function') {
|
||||
onConfirm(notifyConsent);
|
||||
}
|
||||
});
|
||||
|
||||
// 닫기 버튼 이벤트
|
||||
$('#cancelInvitationPopupCancelButton').off('click').on('click', function () {
|
||||
customPopups.hideCancelInvitation();
|
||||
if (typeof onCancel === 'function') {
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
|
||||
// X 버튼 이벤트
|
||||
$('#cancelInvitationPopupCloseButton').off('click').on('click', function () {
|
||||
customPopups.hideCancelInvitation();
|
||||
if (typeof onCancel === 'function') {
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
|
||||
// Escape 키 이벤트
|
||||
$(document).off('keydown.cancelInvitation').on('keydown.cancelInvitation', function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
customPopups.hideCancelInvitation();
|
||||
if (typeof onCancel === 'function') {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 초대취소 팝업 숨기기
|
||||
*/
|
||||
hideCancelInvitation: function () {
|
||||
// Modal 숨김 애니메이션
|
||||
$('#cancelInvitationModalBackdrop').removeClass('show');
|
||||
$('#cancelInvitationModal').removeClass('show');
|
||||
|
||||
// 애니메이션 완료 후 숨김
|
||||
setTimeout(function() {
|
||||
$('#cancelInvitationPopup').hide();
|
||||
}, 300);
|
||||
|
||||
// Body 스크롤 복원
|
||||
$('body').css('overflow', '');
|
||||
|
||||
// 체크박스 초기화
|
||||
$('#cancelInvitationNotifyConsent').prop('checked', false);
|
||||
|
||||
// 이벤트 리스너 제거
|
||||
$('#cancelInvitationPopupConfirmButton').off('click');
|
||||
$('#cancelInvitationPopupCancelButton').off('click');
|
||||
$('#cancelInvitationPopupCloseButton').off('click');
|
||||
$(document).off('keydown.cancelInvitation');
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 직전 또는 직후에 <script> 로 로드
|
||||
*
|
||||
* ?lang=en 쿼리스트링이 있으면 패치 비활성화 (원문 보기)
|
||||
* 매핑 카탈로그는 01-i18n-mapping/i18n-strings.csv 와 동기 유지
|
||||
*/
|
||||
(function (window) {
|
||||
'use strict';
|
||||
|
||||
// ── 비활성화 옵션 ──
|
||||
if (/[?&]lang=en\b/.test(window.location.search)) return;
|
||||
|
||||
// ── 매핑 (key 는 디버깅용, 실제 비교는 EN 문자열 일치) ──
|
||||
var I18N_MAP = {
|
||||
// P0 — OAuth2 모달 본문
|
||||
'auth.modal.title': { en: 'Available authorizations', ko: '사용 가능한 인증' },
|
||||
'auth.scopes.description': {
|
||||
en: 'Scopes are used to grant an application different levels of access to data on behalf of the end user. Each API may declare one or more scopes.',
|
||||
ko: '스코프는 최종 사용자를 대신하여 애플리케이션이 데이터에 접근할 수 있는 권한 수준을 부여합니다. 각 API는 하나 이상의 스코프를 선언할 수 있습니다.'
|
||||
},
|
||||
'auth.scopes.swagger_grant': {
|
||||
en: 'API requires the following scopes. Select which ones you want to grant to Swagger UI.',
|
||||
ko: '이 API 는 아래 스코프가 필요합니다. Swagger UI 에 부여할 스코프를 선택하세요.'
|
||||
},
|
||||
'auth.flow.clientCredentials': { en: 'OAuth2 client credentials flow', ko: 'OAuth2 클라이언트 자격증명 흐름' },
|
||||
'auth.flow.implicit': { en: 'OAuth2 implicit flow', ko: 'OAuth2 암묵적 흐름' },
|
||||
'auth.flow.password': { en: 'OAuth2 password flow', ko: 'OAuth2 비밀번호 흐름' },
|
||||
'auth.flow.authorizationCode': { en: 'OAuth2 authorization code flow', ko: 'OAuth2 인가 코드 흐름' },
|
||||
'auth.field.tokenUrl': { en: 'Token URL:', ko: '토큰 URL:' },
|
||||
'auth.field.flow': { en: 'Flow:', ko: '흐름:' },
|
||||
'auth.field.clientId': { en: 'client_id:', ko: '클라이언트 ID (client_id):' },
|
||||
'auth.field.clientSecret': { en: 'client_secret:',ko: '클라이언트 시크릿 (client_secret):' },
|
||||
'auth.field.scopes': { en: 'Scopes:', ko: '스코프:' },
|
||||
'auth.action.selectAll': { en: 'select all', ko: '모두 선택' },
|
||||
'auth.action.selectNone': { en: 'select none', ko: '선택 해제' },
|
||||
'auth.action.authorize': { en: 'Authorize', ko: '인증' },
|
||||
'auth.action.close': { en: 'Close', ko: '닫기' },
|
||||
'auth.action.logout': { en: 'Logout', ko: '로그아웃' },
|
||||
'auth.modal.authorized': { en: 'authorized', ko: '인증됨' },
|
||||
|
||||
// P1 — 기타 인증 흐름
|
||||
'auth.scheme.apiKey': { en: 'API key', ko: 'API 키' },
|
||||
'auth.field.name': { en: 'name:', ko: '이름:' },
|
||||
'auth.field.in': { en: 'in:', ko: '위치:' },
|
||||
'auth.field.value': { en: 'Value:', ko: '값:' },
|
||||
'basic.field.username': { en: 'Username:',ko: '사용자 ID:' },
|
||||
'basic.field.password': { en: 'Password:',ko: '비밀번호:' },
|
||||
|
||||
// P2 — 오퍼레이션 / 파라미터 / 응답 UI 라벨 (PoC ko-i18n.js 카탈로그와 동기)
|
||||
'op.tryItOut': { en: 'Try it out', ko: '실행해보기' },
|
||||
'op.cancel': { en: 'Cancel', ko: '취소' },
|
||||
'op.execute': { en: 'Execute', ko: '실행' },
|
||||
'op.clear': { en: 'Clear', ko: '지우기' },
|
||||
'op.reset': { en: 'Reset', ko: '초기화' },
|
||||
'op.parameters': { en: 'Parameters', ko: '파라미터' },
|
||||
'op.parameter': { en: 'Parameter', ko: '파라미터' },
|
||||
'op.name': { en: 'Name', ko: '이름' },
|
||||
'op.description': { en: 'Description', ko: '설명' },
|
||||
'op.value': { en: 'Value', ko: '값' },
|
||||
'op.type': { en: 'Type', ko: '타입' },
|
||||
'op.requestBody': { en: 'Request body', ko: '요청 본문' },
|
||||
'op.requestUrl': { en: 'Request URL', ko: '요청 URL' },
|
||||
'op.responses': { en: 'Responses', ko: '응답' },
|
||||
'op.response': { en: 'Response', ko: '응답' },
|
||||
'op.responseBody': { en: 'Response body', ko: '응답 본문' },
|
||||
'op.responseHeaders': { en: 'Response headers', ko: '응답 헤더' },
|
||||
'op.responseCType': { en: 'Response content type', ko: '응답 컨텐츠 타입' },
|
||||
'op.code': { en: 'Code', ko: '코드' },
|
||||
'op.details': { en: 'Details', ko: '상세' },
|
||||
'op.schema': { en: 'Schema', ko: '스키마' },
|
||||
'op.schemas': { en: 'Schemas', ko: '스키마' },
|
||||
'op.exampleValue': { en: 'Example Value', ko: '예시 값' },
|
||||
'op.noParameters': { en: 'No parameters', ko: '파라미터 없음' },
|
||||
'op.required': { en: 'required', ko: '필수' },
|
||||
'op.servers': { en: 'Servers', ko: '서버' },
|
||||
'op.serverResponse': { en: 'Server response', ko: '서버 응답' },
|
||||
'op.loading': { en: 'Loading...', ko: '불러오는 중...' },
|
||||
'op.curlUpper': { en: 'Curl', ko: 'cURL' },
|
||||
'op.curlLower': { en: 'curl', ko: 'cURL' },
|
||||
'op.editValue': { en: 'Edit Value', ko: '값 편집' },
|
||||
'op.model': { en: 'Model', ko: '모델' },
|
||||
'op.snippets': { en: 'Snippets', ko: '스니펫' },
|
||||
'op.mediaType': { en: 'Media type', ko: '미디어 타입' },
|
||||
'op.controlsAccept': { en: 'Controls Accept header.', ko: 'Accept 헤더를 제어합니다.' },
|
||||
'op.sendEmptyValue': { en: 'Send empty value', ko: '빈 값 전송' },
|
||||
'op.hide': { en: 'Hide', ko: '숨기기' },
|
||||
'op.show': { en: 'Show', ko: '표시' }
|
||||
};
|
||||
|
||||
// 빠른 lookup 을 위해 EN → KO 인덱스 사전 생성
|
||||
var EN_TO_KO = Object.create(null);
|
||||
Object.keys(I18N_MAP).forEach(function (k) {
|
||||
var e = I18N_MAP[k];
|
||||
EN_TO_KO[e.en] = e.ko;
|
||||
});
|
||||
|
||||
// ── 치환 대상 노드 식별 + 치환 ──
|
||||
function translateNode(node) {
|
||||
if (!node) return;
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
var raw = node.nodeValue;
|
||||
if (!raw) return;
|
||||
var trimmed = raw.trim();
|
||||
if (!trimmed) return;
|
||||
// 1) 완전 일치 (가장 안전)
|
||||
if (EN_TO_KO[trimmed]) {
|
||||
node.nodeValue = raw.replace(trimmed, EN_TO_KO[trimmed]);
|
||||
return;
|
||||
}
|
||||
// 2) 라벨 colon 패턴: "Token URL:" 같이 끝이 ":" 인 짧은 라벨
|
||||
// (불필요 — 1번 완전 일치로 대부분 커버)
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') return;
|
||||
|
||||
// input placeholder / button title 도 일부 케이스에 대비
|
||||
if (node.tagName === 'INPUT' && node.placeholder && EN_TO_KO[node.placeholder.trim()]) {
|
||||
node.placeholder = EN_TO_KO[node.placeholder.trim()];
|
||||
}
|
||||
|
||||
var i, child = node.childNodes, len = child.length;
|
||||
for (i = 0; i < len; i++) translateNode(child[i]);
|
||||
}
|
||||
|
||||
function translateAll() {
|
||||
var root = document.getElementById('swagger-ui') || document.body;
|
||||
translateNode(root);
|
||||
}
|
||||
|
||||
// ── MutationObserver — SwaggerUI 가 동적으로 DOM 을 다시 그릴 때마다 재적용 ──
|
||||
var observer = new MutationObserver(function (mutations) {
|
||||
for (var i = 0; i < mutations.length; i++) {
|
||||
var m = mutations[i];
|
||||
if (m.addedNodes && m.addedNodes.length) {
|
||||
for (var j = 0; j < m.addedNodes.length; j++) translateNode(m.addedNodes[j]);
|
||||
}
|
||||
if (m.type === 'characterData') {
|
||||
translateNode(m.target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function start() {
|
||||
translateAll();
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})(window);
|
||||
@@ -0,0 +1,350 @@
|
||||
/*!
|
||||
* DjbSwaggerResponse — Swagger UI 기본 라이브 응답 표(.live-responses-table)를 CSS 로
|
||||
* 숨기고, 실행(Execute)한 operation 블록 안에 커스텀 응답 패널을 세로 적층으로 렌더한다.
|
||||
* (요청 스니펫 .request-snippets 은 같은 .responses-wrapper 안에 있으므로 유지)
|
||||
*
|
||||
* ┌─────────────────────────────────────────────┐
|
||||
* │ 응답 헤더 (Table) │
|
||||
* ├─────────────────────────────────────────────┤
|
||||
* │ 응답 본문 (JSON Pretty / Raw) │
|
||||
* ├─────────────────────────────────────────────┤
|
||||
* │ 응답코드 칩 + Response Time(ms) │
|
||||
* └─────────────────────────────────────────────┘
|
||||
*
|
||||
* 포탈 testbed 는 API 하나에 operation 이 여러 개일 수 있으므로, 전역 단일 그리드가
|
||||
* 아니라 Execute 를 누른 opblock 안에 그리드를 만든다(클릭 캡처로 대상 특정).
|
||||
*
|
||||
* 연결:
|
||||
* const ui = SwaggerUIBundle({
|
||||
* responseInterceptor: function (res) { DjbSwaggerResponse.renderResponse(res); return res; }
|
||||
* });
|
||||
* window.ui = ui;
|
||||
* DjbSwaggerResponse.attach('#swagger-ui'); // Execute 클릭 캡처 + 타이머 등록
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var startTs = 0;
|
||||
var targetOpblock = null;
|
||||
|
||||
function now() {
|
||||
return (global.performance && performance.now) ? performance.now() : Date.now();
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function statusKind(status) {
|
||||
if (status >= 200 && status < 300) return "ok";
|
||||
if (status >= 300 && status < 400) return "warn";
|
||||
if (status >= 400) return "err";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function prettyJson(text) {
|
||||
try { return JSON.stringify(JSON.parse(text), null, 2); }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
|
||||
// window.ui 에서 해석된 spec(json) 조회. (snippet-panel 과 동일 방식)
|
||||
function getSpecJson() {
|
||||
try {
|
||||
var ui = global.ui;
|
||||
if (ui && typeof ui.spec === "function") {
|
||||
var s = ui.spec();
|
||||
if (s && typeof s.toJS === "function") {
|
||||
var o = s.toJS();
|
||||
return o.json || o;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* noop */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
// 단일 API 페이지 전제(첫 path/method)로 operation 반환.
|
||||
function getSingleOperation(spec) {
|
||||
var paths = (spec && spec.paths) ? spec.paths : {};
|
||||
var pk = Object.keys(paths)[0];
|
||||
if (!pk) return null;
|
||||
var ops = paths[pk];
|
||||
var mk = ["get", "post", "put", "patch", "delete", "options", "head"].filter(function (m) { return ops[m]; })[0];
|
||||
if (!mk) return null;
|
||||
return { path: pk, method: mk, operation: ops[mk] };
|
||||
}
|
||||
|
||||
// opblock 안 파라미터 입력값(header/query) 읽기.
|
||||
function readParamInput(opblock, name, where) {
|
||||
var scope = opblock || document;
|
||||
var row = scope.querySelector('tr[data-param-name="' + name + '"][data-param-in="' + where + '"]');
|
||||
if (row) {
|
||||
var input = row.querySelector('input[type="text"], input:not([type]), textarea');
|
||||
if (input && input.value) return input.value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Execute 직전 상태에서 검증용 요청 객체를 조립(헤더/쿼리/본문).
|
||||
function buildValidationRequest(opblock, opInfo) {
|
||||
var op = opInfo.operation;
|
||||
var headers = {};
|
||||
var query = [];
|
||||
(op.parameters || []).forEach(function (p) {
|
||||
if (p.in === "header") {
|
||||
var hv = readParamInput(opblock, p.name, "header");
|
||||
if (hv !== undefined) headers[p.name] = hv;
|
||||
} else if (p.in === "query") {
|
||||
var qv = readParamInput(opblock, p.name, "query");
|
||||
if (qv !== undefined) query.push(encodeURIComponent(p.name) + "=" + encodeURIComponent(qv));
|
||||
}
|
||||
});
|
||||
var body = "";
|
||||
if (op.requestBody) {
|
||||
headers["Content-Type"] = headers["Content-Type"] || "application/json";
|
||||
var ta = (opblock || document).querySelector("textarea.body-param__text");
|
||||
if (ta) body = ta.value;
|
||||
}
|
||||
var url = "http://_local" + opInfo.path + (query.length ? "?" + query.join("&") : "");
|
||||
return { url: url, method: opInfo.method.toUpperCase(), headers: headers, body: body };
|
||||
}
|
||||
|
||||
function renderEmpty() {
|
||||
return ''
|
||||
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
|
||||
+ '<div class="empty">요청 중…</div></div>'
|
||||
+ '<div class="cell-body"><div class="cell-title">응답 본문</div>'
|
||||
+ '<div class="empty">요청 중…</div></div>'
|
||||
+ '<div class="cell-meta">'
|
||||
+ '<span class="status-chip neutral">대기</span>'
|
||||
+ '<span class="time">Response Time: -</span>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function renderHeadersTable(headers) {
|
||||
var keys = Object.keys(headers || {});
|
||||
if (!keys.length) return '<div class="empty">헤더가 없습니다.</div>';
|
||||
var rows = keys.map(function (k) {
|
||||
return '<tr><td class="hdr-name">' + escapeHtml(k) + '</td>'
|
||||
+ '<td class="hdr-val">' + escapeHtml(headers[k]) + '</td></tr>';
|
||||
}).join("");
|
||||
return '<table class="hdr-table"><thead><tr><th>이름</th><th>값</th></tr></thead>'
|
||||
+ '<tbody>' + rows + '</tbody></table>';
|
||||
}
|
||||
|
||||
function bodyTabs(rawText) {
|
||||
var pretty = prettyJson(rawText);
|
||||
var hasJson = pretty != null;
|
||||
var jsonTab = hasJson ? '<button type="button" class="body-tab active" data-tab="json">JSON (Pretty)</button>' : "";
|
||||
var rawTab = '<button type="button" class="body-tab' + (hasJson ? "" : " active") + '" data-tab="raw">Raw</button>';
|
||||
var jsonPanel = hasJson ? '<pre class="body-panel mono active" data-tab="json">' + escapeHtml(pretty) + '</pre>' : "";
|
||||
var rawPanel = '<pre class="body-panel mono' + (hasJson ? "" : " active") + '" data-tab="raw">' + escapeHtml(rawText || "") + '</pre>';
|
||||
return '<div class="body-tabs" role="tablist">' + jsonTab + rawTab + '</div>'
|
||||
+ '<div class="body-panels">' + jsonPanel + rawPanel + '</div>';
|
||||
}
|
||||
|
||||
function attachTabHandlers(root) {
|
||||
if (root.__djbTabBound) return;
|
||||
root.__djbTabBound = true;
|
||||
root.addEventListener("click", function (e) {
|
||||
var btn = e.target.closest && e.target.closest(".body-tab");
|
||||
if (!btn) return;
|
||||
var panels = root.querySelectorAll(".body-panel");
|
||||
var tabs = root.querySelectorAll(".body-tab");
|
||||
tabs.forEach(function (t) { t.classList.toggle("active", t === btn); });
|
||||
var which = btn.getAttribute("data-tab");
|
||||
panels.forEach(function (p) { p.classList.toggle("active", p.getAttribute("data-tab") === which); });
|
||||
});
|
||||
}
|
||||
|
||||
// 대상 opblock(없으면 열려있는/첫 opblock) 안 .opblock-body 에 그리드 1개 확보.
|
||||
// 항상 opblock-body 최하단으로 이동시킨다: Execute 시점엔 responses-wrapper(요청
|
||||
// 스니펫)가 아직 없어 그리드가 그 위에 붙지만, 응답 후 재호출 시 최하단으로 옮겨
|
||||
// "Execute → 요청 스니펫 → 응답 그리드" 순서를 보장한다.
|
||||
function gridFor(opblock) {
|
||||
var host = opblock
|
||||
|| document.querySelector("#swagger-ui .opblock.is-open")
|
||||
|| document.querySelector("#swagger-ui .opblock");
|
||||
if (!host) return null;
|
||||
var body = host.querySelector(".opblock-body");
|
||||
if (!body) return null; // 접힘 상태에는 마운트하지 않음 (bare opblock 오배치 방지)
|
||||
var grid = body.querySelector(".djb-response-grid");
|
||||
if (!grid) {
|
||||
grid = document.createElement("div");
|
||||
grid.className = "djb-response-grid djb-empty";
|
||||
grid.innerHTML = renderEmpty();
|
||||
attachTabHandlers(grid);
|
||||
}
|
||||
body.appendChild(grid); // 기존 노드면 최하단으로 이동
|
||||
return grid;
|
||||
}
|
||||
|
||||
// 검증 실패 렌더: 네트워크 요청이 발생하지 않은 상태. djb-error 스타일 그리드.
|
||||
function renderValidationErrors(errors) {
|
||||
var grid = gridFor(targetOpblock);
|
||||
if (!grid) return;
|
||||
var items = (errors || []).map(function (er) {
|
||||
return '<li class="verr-item">'
|
||||
+ '<span class="verr-path">' + escapeHtml(er.path) + '</span> '
|
||||
+ '<span class="verr-msg">' + escapeHtml(er.message) + '</span>'
|
||||
+ (er.value !== undefined ? ' <code class="verr-val mono">' + escapeHtml(JSON.stringify(er.value)) + '</code>' : "")
|
||||
+ '</li>';
|
||||
}).join("");
|
||||
grid.classList.remove("djb-empty");
|
||||
grid.classList.add("djb-error");
|
||||
grid.innerHTML = ''
|
||||
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
|
||||
+ '<div class="empty">검증 실패 — 요청이 전송되지 않았습니다.</div></div>'
|
||||
+ '<div class="cell-body"><div class="cell-title">검증 실패 (' + (errors || []).length + '건)</div>'
|
||||
+ '<ul class="verr-list">' + items + '</ul></div>'
|
||||
+ '<div class="cell-meta">'
|
||||
+ '<span class="status-chip err">검증실패</span>'
|
||||
+ '<span class="time">Response Time: -</span>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
// 네트워크/CORS 실패 렌더: responseInterceptor 로 오지 않고 store 에만 error 로 남는 케이스.
|
||||
function renderNetworkError(msg) {
|
||||
var grid = gridFor(targetOpblock);
|
||||
if (!grid) return;
|
||||
grid.classList.remove("djb-empty");
|
||||
grid.classList.add("djb-error");
|
||||
grid.innerHTML = ''
|
||||
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
|
||||
+ '<div class="empty">응답을 받지 못했습니다.</div></div>'
|
||||
+ '<div class="cell-body"><div class="cell-title">네트워크 오류</div>'
|
||||
+ '<pre class="body-panel mono active" data-tab="raw">요청 실패: ' + escapeHtml(msg) + '</pre></div>'
|
||||
+ '<div class="cell-meta">'
|
||||
+ '<span class="status-chip err">네트워크 오류</span>'
|
||||
+ '<span class="time">Response Time: -</span>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
// Execute 클릭 시점에 대상 opblock 확정 + (검증) + 타이머 시작 + 빈 그리드 표시.
|
||||
// 캡처 단계라 Swagger(React) 의 실행 핸들러보다 먼저 실행됨 → 검증 실패 시
|
||||
// stopImmediatePropagation 으로 native 실행을 차단하고 에러만 렌더한다.
|
||||
function onExecuteClick(e) {
|
||||
var t = e.target;
|
||||
if (!t || !t.closest) return;
|
||||
var btn = t.closest(".btn.execute");
|
||||
if (!btn) return;
|
||||
targetOpblock = btn.closest(".opblock");
|
||||
|
||||
// ── 요청 파라미터/본문 검증 (검증기 로드된 경우) ──
|
||||
if (global.DjbSwaggerValidator) {
|
||||
try {
|
||||
var spec = getSpecJson();
|
||||
var opInfo = spec && getSingleOperation(spec);
|
||||
if (opInfo) {
|
||||
var req = buildValidationRequest(targetOpblock, opInfo);
|
||||
var result = global.DjbSwaggerValidator.validateOperation(opInfo.operation, req, spec);
|
||||
if (!result.ok) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
renderValidationErrors(result.errors);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) { console.error("djb validation error:", err); }
|
||||
}
|
||||
|
||||
startTs = now();
|
||||
var grid = gridFor(targetOpblock);
|
||||
if (grid) {
|
||||
grid.classList.add("djb-empty");
|
||||
grid.classList.remove("djb-error");
|
||||
grid.innerHTML = renderEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} res Swagger UI responseInterceptor 결과
|
||||
* { status, statusText, headers, text, data, ... }
|
||||
*/
|
||||
function renderResponse(res) {
|
||||
if (!res) return;
|
||||
// spec 로딩 응답(/…/swagger.json)은 responseInterceptor 로도 들어온다. 이때 렌더하면
|
||||
// 실행 전인데 응답 본문에 OpenAPI spec 이 그려지므로 무시한다. 사용자 API 호출은
|
||||
// 프록시(/api/call-api)로 나가므로 url 에 swagger.json 이 없다.
|
||||
if (res.url && res.url.indexOf("swagger.json") !== -1) return;
|
||||
var ms = Math.max(0, Math.round(now() - startTs));
|
||||
var status = res.status || 0;
|
||||
var statusText = res.statusText || "";
|
||||
var headers = res.headers || {};
|
||||
var rawText = (typeof res.text === "string") ? res.text
|
||||
: (typeof res.data === "string") ? res.data
|
||||
: (res.data != null ? JSON.stringify(res.data) : "");
|
||||
var op = targetOpblock;
|
||||
|
||||
// Swagger(React) 가 응답 렌더로 opblock-body 를 재조정한 뒤 append 되도록 지연.
|
||||
setTimeout(function () {
|
||||
var grid = gridFor(op);
|
||||
if (!grid) return;
|
||||
var kind = statusKind(status);
|
||||
grid.classList.remove("djb-empty", "djb-error");
|
||||
grid.innerHTML = ''
|
||||
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
|
||||
+ renderHeadersTable(headers) + '</div>'
|
||||
+ '<div class="cell-body"><div class="cell-title">응답 본문</div>'
|
||||
+ bodyTabs(rawText) + '</div>'
|
||||
+ '<div class="cell-meta">'
|
||||
+ '<span class="status-chip ' + kind + '">응답코드: ' + escapeHtml(status)
|
||||
+ (statusText ? " " + escapeHtml(statusText) : "") + '</span>'
|
||||
+ '<span class="time">Response Time: ' + ms + ' ms</span>'
|
||||
+ '</div>';
|
||||
attachTabHandlers(grid);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// fetch 실패(네트워크/CORS/타임아웃)는 responseInterceptor 로 전달되지 않고 store 의
|
||||
// spec.responses[path][method] 에 { error:true, err } 로만 저장된다. store 를 구독해
|
||||
// 사용자에게 네트워크 오류 그리드로 보여준다. (PoC bootstrap 의 getStore().subscribe 이식)
|
||||
function watchNetworkErrors(ui) {
|
||||
if (!ui || typeof ui.getStore !== "function" || ui.__djbNetWatch) return;
|
||||
ui.__djbNetWatch = true;
|
||||
var rendered = {};
|
||||
ui.getStore().subscribe(function () {
|
||||
try {
|
||||
var respMap = ui.getState().getIn(["spec", "responses"]);
|
||||
if (!respMap || typeof respMap.forEach !== "function") return;
|
||||
respMap.forEach(function (methods, path) {
|
||||
if (!methods || typeof methods.forEach !== "function") return;
|
||||
methods.forEach(function (resp, method) {
|
||||
var key = path + " " + method;
|
||||
var hasError = resp && (resp.get ? resp.get("error") : resp.error);
|
||||
if (hasError) {
|
||||
if (rendered[key]) return;
|
||||
rendered[key] = true;
|
||||
var err = resp.get ? resp.get("err") : resp.err;
|
||||
var msg = (err && (err.message || (err.get && err.get("message"))))
|
||||
|| (err ? String(err) : "응답을 받지 못했습니다.");
|
||||
renderNetworkError(String(msg));
|
||||
} else {
|
||||
rendered[key] = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) { /* ignore */ }
|
||||
});
|
||||
}
|
||||
|
||||
// #swagger-ui 는 API 전환 시에도 element 자체는 유지되므로 캡처 리스너 1회 등록으로 충분.
|
||||
function attach(rootSel) {
|
||||
var root = document.querySelector(rootSel || "#swagger-ui");
|
||||
if (!root || root.__djbRespBound) return;
|
||||
root.__djbRespBound = true;
|
||||
// capture 단계: native 응답 흐름보다 먼저 타겟/타이머 확보 + 검증 차단
|
||||
root.addEventListener("click", onExecuteClick, true);
|
||||
// window.ui 준비 시 네트워크 오류 감시 등록
|
||||
watchNetworkErrors(global.ui);
|
||||
}
|
||||
|
||||
global.DjbSwaggerResponse = {
|
||||
attach: attach,
|
||||
renderResponse: renderResponse,
|
||||
renderValidationErrors: renderValidationErrors,
|
||||
renderNetworkError: renderNetworkError,
|
||||
watchNetworkErrors: watchNetworkErrors
|
||||
};
|
||||
})(window);
|
||||
@@ -0,0 +1,265 @@
|
||||
/*!
|
||||
* DjbSwaggerSnippetPanel — Execute 없이 페이지 진입 즉시 요청 스니펫을 보여주는 상시 패널.
|
||||
* (PoC rinjae-works/swagger-poc/js/snippet-panel.js 를 포탈용으로 이식)
|
||||
*
|
||||
* Swagger UI 5 의 내장 .request-snippets 는 Execute 이후에만, 그리고 응답영역 안에
|
||||
* 렌더되므로 CSS 로 숨기고, 동일한 window.SnippetGenerators 로 직접 패널을 만들어
|
||||
* operation 의 Execute 버튼(.btn-group) 바로 아래에 마운트한다. Body/헤더 변경 시 자동 갱신.
|
||||
*
|
||||
* 전제: 이 페이지는 항상 1개 API(단일 operation) 노출 — spec 의 첫 path/method 사용.
|
||||
* spec 은 window.ui 에서 지연 조회한다.
|
||||
*
|
||||
* 연결: SwaggerUIBundle 의 onComplete 에서 DjbSwaggerSnippetPanel.mount() 호출.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var TABS = [
|
||||
{ key: "curl_bash", title: "cURL (Bash)", gen: "curlBash" },
|
||||
{ key: "curl_powershell", title: "cURL (PowerShell)", gen: "curlPwsh" },
|
||||
{ key: "curl_cmd", title: "cURL (CMD)", gen: "curlCmd" },
|
||||
{ key: "csharp", title: "C#", gen: "csharp" },
|
||||
{ key: "ecma5", title: "ECMA5 (JavaScript)", gen: "ecma5" },
|
||||
{ key: "java", title: "Java", gen: "javaOkhttp" },
|
||||
{ key: "python", title: "Python", gen: "python" }
|
||||
];
|
||||
|
||||
var activeKey = "curl_bash";
|
||||
var rootSel = "#swagger-ui";
|
||||
var observer = null;
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// window.ui 에서 해석된 spec(json) 조회.
|
||||
function getSpecJson() {
|
||||
try {
|
||||
var ui = global.ui;
|
||||
if (ui && typeof ui.spec === "function") {
|
||||
var s = ui.spec();
|
||||
if (s && typeof s.toJS === "function") {
|
||||
var o = s.toJS();
|
||||
return o.json || o;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* noop */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
// Path 1 + Method 1 전제 하에 단일 operation 반환.
|
||||
function getSingleOperation(spec) {
|
||||
var paths = (spec && spec.paths) ? spec.paths : {};
|
||||
var pk = Object.keys(paths)[0];
|
||||
if (!pk) return null;
|
||||
var ops = paths[pk];
|
||||
var mk = ["get", "post", "put", "patch", "delete", "options", "head"].filter(function (m) { return ops[m]; })[0];
|
||||
if (!mk) return null;
|
||||
return { path: pk, method: mk, operation: ops[mk] };
|
||||
}
|
||||
|
||||
function readHeaderInput(name) {
|
||||
var row = document.querySelector('tr[data-param-name="' + name + '"][data-param-in="header"]');
|
||||
if (row) {
|
||||
var input = row.querySelector('input[type="text"], input:not([type])');
|
||||
if (input && input.value) return input.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Authorize 모달 사용 후에만 표시되는 인증 헤더 추출.
|
||||
function readAuthHeaders() {
|
||||
var headers = {};
|
||||
try {
|
||||
var ui = global.ui;
|
||||
if (!ui || !ui.authSelectors) return headers;
|
||||
var auths = ui.authSelectors.authorized();
|
||||
if (!auths) return headers;
|
||||
var js = auths.toJS();
|
||||
Object.keys(js).forEach(function (key) {
|
||||
var a = js[key];
|
||||
if (!a || !a.schema) return;
|
||||
var scheme = a.schema;
|
||||
if (scheme.type === "oauth2") {
|
||||
var tok = a.token && a.token.access_token;
|
||||
if (tok) headers["Authorization"] = "Bearer " + tok;
|
||||
} else if (scheme.type === "apiKey" && scheme.in === "header") {
|
||||
if (a.value) headers[scheme.name] = a.value;
|
||||
} else if (scheme.type === "http" && scheme.scheme === "bearer") {
|
||||
if (a.value) headers["Authorization"] = "Bearer " + a.value;
|
||||
}
|
||||
});
|
||||
} catch (e) { /* noop */ }
|
||||
return headers;
|
||||
}
|
||||
|
||||
function buildRequest() {
|
||||
var spec = getSpecJson();
|
||||
var opInfo = spec && getSingleOperation(spec);
|
||||
if (!opInfo) return null;
|
||||
var op = opInfo.operation;
|
||||
var server = (spec.servers && spec.servers[0] && spec.servers[0].url) || "";
|
||||
var url = server + opInfo.path;
|
||||
|
||||
var headers = {};
|
||||
(op.parameters || []).forEach(function (p) {
|
||||
if (p.in !== "header") return;
|
||||
var val = readHeaderInput(p.name)
|
||||
|| (p.example !== undefined ? String(p.example) : null)
|
||||
|| (p.schema && p.schema.example !== undefined ? String(p.schema.example) : null);
|
||||
if (val) headers[p.name] = val;
|
||||
});
|
||||
|
||||
var body = "";
|
||||
if (op.requestBody && op.requestBody.content) {
|
||||
var cts = Object.keys(op.requestBody.content);
|
||||
var ct = cts[0] || "application/json";
|
||||
headers["Content-Type"] = ct;
|
||||
var ta = document.querySelector(rootSel + " textarea.body-param__text");
|
||||
if (ta && ta.value) {
|
||||
body = ta.value;
|
||||
} else if (op.requestBody.content[ct] && op.requestBody.content[ct].example !== undefined) {
|
||||
try { body = JSON.stringify(op.requestBody.content[ct].example, null, 2); } catch (e) { body = ""; }
|
||||
}
|
||||
}
|
||||
headers["accept"] = headers["accept"] || "application/json";
|
||||
var auth = readAuthHeaders();
|
||||
Object.keys(auth).forEach(function (k) { headers[k] = auth[k]; });
|
||||
|
||||
return { url: url, method: opInfo.method.toUpperCase(), headers: headers, body: body };
|
||||
}
|
||||
|
||||
function panelEl() { return document.querySelector("#djb-snippet-panel"); }
|
||||
|
||||
function renderInto(el) {
|
||||
if (!el) return;
|
||||
var gens = global.SnippetGenerators;
|
||||
var code;
|
||||
if (!gens) {
|
||||
code = "// 스니펫 생성기 로드 대기…";
|
||||
} else {
|
||||
try {
|
||||
var req = buildRequest();
|
||||
if (!req) { code = "// 스펙 로딩 중…"; }
|
||||
else {
|
||||
var active = TABS.filter(function (t) { return t.key === activeKey; })[0] || TABS[0];
|
||||
code = gens[active.gen](req);
|
||||
}
|
||||
} catch (e) { code = "// 스니펫 생성 오류: " + e.message; }
|
||||
}
|
||||
var tabsHtml = TABS.map(function (t) {
|
||||
return '<button type="button" class="djb-sn-tab ' + (t.key === activeKey ? "active" : "") + '" data-key="' + t.key + '">' + t.title + '</button>';
|
||||
}).join("");
|
||||
el.innerHTML =
|
||||
'<div class="djb-sn-header">'
|
||||
+ '<h4>요청 스니펫</h4>'
|
||||
+ '<span class="djb-sn-hint">(실행 없이 즉시 미리보기 — Body / 헤더 변경 시 자동 갱신)</span>'
|
||||
+ '<button type="button" class="djb-sn-copy" data-action="copy">복사</button>'
|
||||
+ '</div>'
|
||||
+ '<div class="djb-sn-tabs" role="tablist">' + tabsHtml + '</div>'
|
||||
+ '<pre class="djb-sn-code mono">' + escapeHtml(code) + '</pre>';
|
||||
}
|
||||
|
||||
// 옵저버 재진입(무한 루프) 방지: 우리 DOM 변경 중에는 관찰 일시 중지.
|
||||
function withObserverPaused(fn) {
|
||||
if (observer) observer.disconnect();
|
||||
try { fn(); }
|
||||
finally {
|
||||
if (observer) {
|
||||
var root = document.querySelector(rootSel);
|
||||
if (root) observer.observe(root, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute 영역 바로 아래 = Swagger UI 폼 하단에 패널 확보.
|
||||
// - 앵커는 opblock-body '직속' 자식만 본다: 실행 전에는 .execute-wrapper, 실행 후에는
|
||||
// Swagger UI 5 가 이를 .btn-group(실행+초기화 버튼군)으로 '교체'하므로 둘 다 허용한다.
|
||||
// (execute-wrapper 만 앵커로 쓰면 실행 후 앵커 소실 → 패널이 제거되고 재생성 안 됨.)
|
||||
// - 중첩 .btn-group(파라미터 섹션 헤더의 try-out '취소' 버튼군)은 직속 자식이 아니라
|
||||
// 건너뛴다: 그걸 앵커로 잡으면 flex 헤더 안에 삽입돼 "파라미터" 라벨이 찌부된다.
|
||||
// - operation 이 접혀 opblock-body/Execute 영역이 없으면: 잘못 남은 패널을 제거하고 중단.
|
||||
// 다음 펼침 때 재생성.
|
||||
// - 이미 있으나 위치가 어긋나면: anchor 바로 뒤로 재배치.
|
||||
function ensureMounted() {
|
||||
var op = document.querySelector(rootSel + " .opblock.is-open")
|
||||
|| document.querySelector(rootSel + " .opblock");
|
||||
var body = op && op.querySelector(".opblock-body");
|
||||
var anchor = null;
|
||||
if (body) {
|
||||
for (var i = 0; i < body.children.length; i++) {
|
||||
var kid = body.children[i];
|
||||
if (kid.classList.contains("execute-wrapper") || kid.classList.contains("btn-group")) {
|
||||
anchor = kid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
var existing = panelEl();
|
||||
|
||||
if (!body || !anchor) {
|
||||
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
if (existing.parentNode !== anchor.parentNode || existing.previousElementSibling !== anchor) {
|
||||
anchor.parentNode.insertBefore(existing, anchor.nextSibling);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var panel = document.createElement("div");
|
||||
panel.id = "djb-snippet-panel";
|
||||
panel.className = "djb-snippet-panel";
|
||||
anchor.parentNode.insertBefore(panel, anchor.nextSibling);
|
||||
renderInto(panel);
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
var tab = e.target.closest && e.target.closest("#djb-snippet-panel .djb-sn-tab");
|
||||
if (tab) {
|
||||
activeKey = tab.getAttribute("data-key");
|
||||
withObserverPaused(function () { renderInto(panelEl()); });
|
||||
return;
|
||||
}
|
||||
var copy = e.target.closest && e.target.closest('#djb-snippet-panel [data-action="copy"]');
|
||||
if (copy) {
|
||||
var code = document.querySelector("#djb-snippet-panel .djb-sn-code");
|
||||
if (code && global.navigator && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(code.innerText).then(function () {
|
||||
copy.textContent = "복사됨";
|
||||
setTimeout(function () { copy.textContent = "복사"; }, 1200);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onInput(e) {
|
||||
var t = e.target;
|
||||
if (!t || !t.matches) return;
|
||||
if (t.matches("textarea.body-param__text") || t.matches("tr[data-param-name] input")) {
|
||||
withObserverPaused(function () { renderInto(panelEl()); });
|
||||
}
|
||||
}
|
||||
|
||||
function mount(opts) {
|
||||
rootSel = (opts && opts.rootSel) || "#swagger-ui";
|
||||
var root = document.querySelector(rootSel);
|
||||
if (!root) return;
|
||||
if (!root.__djbSnBound) {
|
||||
root.__djbSnBound = true;
|
||||
root.addEventListener("click", onClick);
|
||||
root.addEventListener("input", onInput);
|
||||
observer = new MutationObserver(function () {
|
||||
withObserverPaused(function () { ensureMounted(); });
|
||||
});
|
||||
observer.observe(root, { childList: true, subtree: true });
|
||||
}
|
||||
withObserverPaused(function () { ensureMounted(); });
|
||||
}
|
||||
|
||||
global.DjbSwaggerSnippetPanel = { mount: mount, render: function () { renderInto(panelEl()); } };
|
||||
})(window);
|
||||
@@ -0,0 +1,223 @@
|
||||
/*!
|
||||
* DjbSwaggerSnippets — Swagger UI 의 SnippetGeneratorPlugin 에 등록되는 7개 언어
|
||||
* 클라이언트 코드 생성기. 서버측 /api/sample_code/{language} 호출을 대체한다.
|
||||
*
|
||||
* cURL (Bash) / cURL (PowerShell) / cURL (CMD) / C# / ECMA5 (JS) / Java / Python
|
||||
*
|
||||
* 입력: Swagger UI requestSnippetGenerator 가 넘기는 Immutable request.
|
||||
* .toJS() 로 plain object 변환 후 url/method/headers/body 사용.
|
||||
* (showMutatedRequest:false 이므로 프록시 URL 이 아닌 실제 대상 요청이 넘어온다)
|
||||
* 출력: string (코드 본문)
|
||||
*
|
||||
* 전역: window.SnippetGeneratorPlugin (Swagger plugins 배열에 등록),
|
||||
* window.SnippetGenerators (개별 생성 함수 접근용)
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
function unwrap(req) {
|
||||
if (!req) return {};
|
||||
if (typeof req.toJS === "function") return req.toJS();
|
||||
return req;
|
||||
}
|
||||
|
||||
function getCt(headers) {
|
||||
for (const k of Object.keys(headers || {})) {
|
||||
if (k.toLowerCase() === "content-type") return headers[k];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function bodyAsString(body) {
|
||||
if (body === undefined || body === null) return "";
|
||||
if (typeof body === "string") return body;
|
||||
try { return JSON.stringify(body); } catch (e) { return String(body); }
|
||||
}
|
||||
|
||||
function escapeSingleQuote(s) { return String(s).replace(/'/g, "'\\''"); }
|
||||
function escapeDoubleQuote(s) { return String(s).replace(/"/g, '\\"'); }
|
||||
|
||||
// ─── cURL (Bash) ────────────────────────────────────────────────────────────
|
||||
function curlBash(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const lines = [`curl --location --request ${r.method || "GET"} '${escapeSingleQuote(r.url)}'`];
|
||||
const headers = r.headers || {};
|
||||
for (const k of Object.keys(headers)) {
|
||||
lines.push(` --header '${escapeSingleQuote(k)}: ${escapeSingleQuote(headers[k])}'`);
|
||||
}
|
||||
const body = bodyAsString(r.body);
|
||||
if (body) {
|
||||
lines.push(` --data-raw '${escapeSingleQuote(body)}'`);
|
||||
}
|
||||
return lines.join(" \\\n");
|
||||
}
|
||||
|
||||
// ─── cURL (PowerShell) ─────────────────────────────────────────────────────
|
||||
function curlPwsh(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
// 백틱(`) 줄 연속자
|
||||
const segments = [`curl.exe --location --request ${r.method || "GET"} "${escapeDoubleQuote(r.url)}"`];
|
||||
const headers = r.headers || {};
|
||||
for (const k of Object.keys(headers)) {
|
||||
segments.push(` --header "${escapeDoubleQuote(k)}: ${escapeDoubleQuote(headers[k])}"`);
|
||||
}
|
||||
const body = bodyAsString(r.body);
|
||||
if (body) {
|
||||
segments.push(` --data-raw "${escapeDoubleQuote(body)}"`);
|
||||
}
|
||||
return segments.join(" `\n");
|
||||
}
|
||||
|
||||
// ─── cURL (Windows CMD) ────────────────────────────────────────────────────
|
||||
function curlCmd(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const segments = [`curl.exe --location --request ${r.method || "GET"} "${escapeDoubleQuote(r.url)}"`];
|
||||
const headers = r.headers || {};
|
||||
for (const k of Object.keys(headers)) {
|
||||
segments.push(` --header "${escapeDoubleQuote(k)}: ${escapeDoubleQuote(headers[k])}"`);
|
||||
}
|
||||
const body = bodyAsString(r.body);
|
||||
if (body) {
|
||||
segments.push(` --data-raw "${escapeDoubleQuote(body)}"`);
|
||||
}
|
||||
return segments.join(" ^\n");
|
||||
}
|
||||
|
||||
// ─── C# (RestSharp) ─────────────────────────────────────────────────────────
|
||||
function csharp(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const method = (r.method || "GET").toUpperCase();
|
||||
const headers = r.headers || {};
|
||||
const body = bodyAsString(r.body);
|
||||
const lines = [
|
||||
`using RestSharp;`,
|
||||
``,
|
||||
`var client = new RestClient("${escapeDoubleQuote(r.url)}");`,
|
||||
`var request = new RestRequest(Method.${method});`,
|
||||
];
|
||||
for (const k of Object.keys(headers)) {
|
||||
lines.push(`request.AddHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}");`);
|
||||
}
|
||||
if (body) {
|
||||
const ct = getCt(headers) || "application/json";
|
||||
lines.push(`request.AddParameter("${escapeDoubleQuote(ct)}", @"${body.replace(/"/g, '""')}", ParameterType.RequestBody);`);
|
||||
}
|
||||
lines.push(`IRestResponse response = client.Execute(request);`);
|
||||
lines.push(`Console.WriteLine(response.Content);`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── ECMA5 / JavaScript (XHR) ──────────────────────────────────────────────
|
||||
function ecma5(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const method = (r.method || "GET").toUpperCase();
|
||||
const headers = r.headers || {};
|
||||
const body = bodyAsString(r.body);
|
||||
const lines = [
|
||||
`var xhr = new XMLHttpRequest();`,
|
||||
`xhr.withCredentials = false;`,
|
||||
``,
|
||||
`xhr.addEventListener("readystatechange", function () {`,
|
||||
` if (this.readyState === 4) {`,
|
||||
` console.log(this.responseText);`,
|
||||
` }`,
|
||||
`});`,
|
||||
``,
|
||||
`xhr.open("${method}", "${escapeDoubleQuote(r.url)}");`,
|
||||
];
|
||||
for (const k of Object.keys(headers)) {
|
||||
lines.push(`xhr.setRequestHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}");`);
|
||||
}
|
||||
if (body) {
|
||||
// ES5 라 template literal 사용 X — JSON.stringify 로 안전한 문자열 리터럴 생성
|
||||
lines.push(`xhr.send(${JSON.stringify(body)});`);
|
||||
} else {
|
||||
lines.push(`xhr.send(null);`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── Java (OkHttp) ─────────────────────────────────────────────────────────
|
||||
function javaOkhttp(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const method = (r.method || "GET").toUpperCase();
|
||||
const headers = r.headers || {};
|
||||
const body = bodyAsString(r.body);
|
||||
const ct = getCt(headers) || "application/json";
|
||||
const lines = [
|
||||
`OkHttpClient client = new OkHttpClient().newBuilder().build();`,
|
||||
`MediaType mediaType = MediaType.parse("${escapeDoubleQuote(ct)}");`,
|
||||
];
|
||||
if (body) {
|
||||
lines.push(`RequestBody body = RequestBody.create(mediaType, "${escapeDoubleQuote(body)}");`);
|
||||
}
|
||||
lines.push(`Request request = new Request.Builder()`);
|
||||
lines.push(` .url("${escapeDoubleQuote(r.url)}")`);
|
||||
if (body) {
|
||||
lines.push(` .method("${method}", body)`);
|
||||
} else {
|
||||
lines.push(` .method("${method}", null)`);
|
||||
}
|
||||
for (const k of Object.keys(headers)) {
|
||||
lines.push(` .addHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}")`);
|
||||
}
|
||||
lines.push(` .build();`);
|
||||
lines.push(`Response response = client.newCall(request).execute();`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── Python (requests) ─────────────────────────────────────────────────────
|
||||
function python(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const method = (r.method || "GET").toLowerCase();
|
||||
const headers = r.headers || {};
|
||||
const body = bodyAsString(r.body);
|
||||
|
||||
const headerEntries = Object.keys(headers).map(
|
||||
k => ` "${escapeDoubleQuote(k)}": "${escapeDoubleQuote(headers[k])}"`
|
||||
);
|
||||
const lines = [
|
||||
`import requests`,
|
||||
``,
|
||||
`url = "${escapeDoubleQuote(r.url)}"`,
|
||||
``,
|
||||
];
|
||||
if (body) {
|
||||
lines.push(`payload = ${JSON.stringify(body)}`);
|
||||
}
|
||||
lines.push(`headers = {`);
|
||||
if (headerEntries.length) lines.push(headerEntries.join(",\n"));
|
||||
lines.push(`}`);
|
||||
lines.push(``);
|
||||
if (body) {
|
||||
lines.push(`response = requests.request("${method.toUpperCase()}", url, headers=headers, data=payload)`);
|
||||
} else {
|
||||
lines.push(`response = requests.request("${method.toUpperCase()}", url, headers=headers)`);
|
||||
}
|
||||
lines.push(`print(response.text)`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── Swagger UI 플러그인 등록 페이로드 ──────────────────────────────────────
|
||||
const SnippetGeneratorPlugin = function () {
|
||||
return {
|
||||
fn: {
|
||||
// curl_bash / curl_powershell / curl_cmd 는 Swagger UI 5 내장 기본 키와 동일하게
|
||||
// 맞춰 내장 생성기를 덮어쓴다. (curl_pwsh 등 다른 키를 쓰면 내장 PowerShell 탭이
|
||||
// 중복 표시됨)
|
||||
requestSnippetGenerator_curl_bash: curlBash,
|
||||
requestSnippetGenerator_curl_powershell: curlPwsh,
|
||||
requestSnippetGenerator_curl_cmd: curlCmd,
|
||||
requestSnippetGenerator_csharp: csharp,
|
||||
requestSnippetGenerator_ecma5: ecma5,
|
||||
requestSnippetGenerator_java: javaOkhttp,
|
||||
requestSnippetGenerator_python: python
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
global.SnippetGeneratorPlugin = SnippetGeneratorPlugin;
|
||||
global.SnippetGenerators = {
|
||||
curlBash, curlPwsh, curlCmd, csharp, ecma5, javaOkhttp, python
|
||||
};
|
||||
})(window);
|
||||
@@ -0,0 +1,314 @@
|
||||
/* ─────────────────────────────────────────────────────────────────────────── */
|
||||
/* DJB Swagger Testbed overlay */
|
||||
/* - 기본 라이브 응답 표(.live-responses-table) 숨김 (스니펫은 유지) */
|
||||
/* - 실행한 operation 안에 세로 적층 커스텀 응답 패널(.djb-response-grid) */
|
||||
/* - D2Coding 우선 monospace fallback */
|
||||
/* 포탈 통합용. PoC(rinjae-works/swagger-poc/css/overlay.css) 에서 응답 파트만 */
|
||||
/* 발췌·클래스화(단일 ID → 다중 op 대응). */
|
||||
/* ─────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* D2Coding 은 시스템에 설치된 경우에만 사용 (local()). 미설치 시 fallback 체인 사용. */
|
||||
@font-face {
|
||||
font-family: "D2Coding";
|
||||
src: local("D2Coding"),
|
||||
local("D2Coding-Regular"),
|
||||
local("D2Coding ligature");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--djb-mono: "D2Coding", "Menlo", "Consolas", "Courier New", monospace;
|
||||
--djb-border: #d0d7de;
|
||||
--djb-bg: #ffffff;
|
||||
--djb-bg-alt: #f6f8fa;
|
||||
--djb-text: #1f2328;
|
||||
--djb-text-muted: #6e7781;
|
||||
--djb-ok: #1f883d;
|
||||
--djb-warn: #bf8700;
|
||||
--djb-err: #cf222e;
|
||||
--djb-accent: #0969da;
|
||||
}
|
||||
|
||||
/* ─── Swagger UI 기본 응답영역: 문서화 응답 샘플만 노출 ────────────────────────
|
||||
응답 영역(.responses-wrapper)은 표시하되(= 상태코드별 응답 예제/스키마 = "응답 샘플"),
|
||||
실행 결과 라이브 블록은 숨긴다:
|
||||
- 요청 스니펫(.request-snippets) → 상시 다크 패널(#djb-snippet-panel)로 대체
|
||||
- 라이브 응답(Curl/Request URL/Server response + .live-responses-table)
|
||||
= .responses-inner 의 직접 자식 <div> → 커스텀 그리드(.djb-response-grid)로 대체
|
||||
문서화 응답 예제는 .responses-inner > table.responses-table 이라 <div> 숨김과 구분됨.
|
||||
최종 순서: 파라미터 → 요청 본문 → Execute → 요청 스니펫(패널) → 응답 샘플(표) → 응답 그리드. */
|
||||
.swagger-ui .request-snippets { display: none !important; }
|
||||
.swagger-ui .responses-inner > div { display: none !important; }
|
||||
.swagger-ui .live-responses-table { display: none !important; }
|
||||
|
||||
/* ─── 상시 요청 스니펫 패널 (PoC overlay.css #djb-snippet-panel) ─────────────── */
|
||||
.swagger-ui #djb-snippet-panel {
|
||||
margin: 16px;
|
||||
padding: 12px 16px 16px;
|
||||
background: var(--djb-bg);
|
||||
border: 1px solid var(--djb-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-header h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--djb-text);
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-hint {
|
||||
color: var(--djb-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-copy {
|
||||
margin-left: auto;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: var(--djb-bg-alt);
|
||||
border: 1px solid var(--djb-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--djb-text);
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-copy:hover { background: #eaeef2; }
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--djb-border);
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-tab {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--djb-text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px 4px 0 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-tab:hover { color: var(--djb-text); }
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-tab.active {
|
||||
color: var(--djb-accent);
|
||||
border-color: var(--djb-border);
|
||||
background: var(--djb-bg);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-code {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
border-radius: 6px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: var(--djb-mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* 한글 라벨이 길어도 깨지지 않도록 */
|
||||
.swagger-ui .opblock .opblock-summary-path,
|
||||
.swagger-ui .opblock-tag,
|
||||
.swagger-ui table thead tr th,
|
||||
.swagger-ui .parameter__name,
|
||||
.swagger-ui .parameter__type {
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
/* ─── 커스텀 응답 그리드: 헤더 → 본문 → 메타 세로 적층 (opblock 마다 1개) ── */
|
||||
.swagger-ui .djb-response-grid {
|
||||
margin: 16px;
|
||||
padding: 16px;
|
||||
background: var(--djb-bg);
|
||||
border: 1px solid var(--djb-border);
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"headers"
|
||||
"body"
|
||||
"meta";
|
||||
gap: 12px;
|
||||
color: var(--djb-text);
|
||||
}
|
||||
.swagger-ui .djb-response-grid.djb-error { border-color: var(--djb-err); background: #fff8f8; }
|
||||
|
||||
.swagger-ui .djb-response-grid .cell-headers { grid-area: headers; min-width: 0; }
|
||||
.swagger-ui .djb-response-grid .cell-body { grid-area: body; min-width: 0; }
|
||||
.swagger-ui .djb-response-grid .cell-meta {
|
||||
grid-area: meta;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--djb-border);
|
||||
}
|
||||
|
||||
.swagger-ui .djb-response-grid .cell-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--djb-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.swagger-ui .djb-response-grid .empty {
|
||||
color: var(--djb-text-muted);
|
||||
font-size: 13px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* Header 테이블 */
|
||||
.swagger-ui .djb-response-grid .hdr-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .hdr-table th,
|
||||
.swagger-ui .djb-response-grid .hdr-table td {
|
||||
border-bottom: 1px solid var(--djb-border);
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
word-break: break-all;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .hdr-table thead th {
|
||||
background: var(--djb-bg-alt);
|
||||
font-weight: 600;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .hdr-table td.hdr-name {
|
||||
font-weight: 600;
|
||||
color: var(--djb-text);
|
||||
width: 40%;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .hdr-table td.hdr-val {
|
||||
font-family: var(--djb-mono);
|
||||
font-size: 12px;
|
||||
color: var(--djb-text);
|
||||
}
|
||||
|
||||
/* Body 탭 */
|
||||
.swagger-ui .djb-response-grid .body-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--djb-border);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .body-tab {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--djb-text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .body-tab:hover { color: var(--djb-text); }
|
||||
.swagger-ui .djb-response-grid .body-tab.active {
|
||||
color: var(--djb-accent);
|
||||
border-color: var(--djb-border);
|
||||
background: var(--djb-bg);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.swagger-ui .djb-response-grid .body-panels {
|
||||
position: relative;
|
||||
min-height: 80px;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .body-panel {
|
||||
display: none;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
border-radius: 6px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .body-panel.active { display: block; }
|
||||
.swagger-ui .djb-response-grid .mono { font-family: var(--djb-mono); }
|
||||
|
||||
/* Meta 칩 / 시간 */
|
||||
.swagger-ui .djb-response-grid .status-chip {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-family: var(--djb-mono);
|
||||
color: #fff;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .status-chip.ok { background: var(--djb-ok); }
|
||||
.swagger-ui .djb-response-grid .status-chip.warn { background: var(--djb-warn); }
|
||||
.swagger-ui .djb-response-grid .status-chip.err { background: var(--djb-err); }
|
||||
.swagger-ui .djb-response-grid .status-chip.neutral { background: var(--djb-text-muted); }
|
||||
|
||||
.swagger-ui .djb-response-grid .time {
|
||||
font-family: var(--djb-mono);
|
||||
font-size: 12px;
|
||||
color: var(--djb-text-muted);
|
||||
}
|
||||
|
||||
/* ─── 검증 실패 목록 (djb-swagger-validator.js) ─────────────────────────────── */
|
||||
.swagger-ui .djb-response-grid .verr-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .verr-item {
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
background: #fff8f8;
|
||||
border: 1px solid #ffd7d5;
|
||||
border-left: 3px solid var(--djb-err);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .verr-path {
|
||||
font-family: var(--djb-mono);
|
||||
font-weight: 700;
|
||||
color: var(--djb-err);
|
||||
}
|
||||
.swagger-ui .djb-response-grid .verr-msg { color: var(--djb-text); }
|
||||
.swagger-ui .djb-response-grid .verr-val {
|
||||
display: inline-block;
|
||||
margin-top: 2px;
|
||||
padding: 1px 6px;
|
||||
background: var(--djb-bg-alt);
|
||||
border: 1px solid var(--djb-border);
|
||||
border-radius: 3px;
|
||||
color: var(--djb-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ─── 다크 코드 패널 텍스트 선택 가독성 ──────────────────────────────────────
|
||||
기본 ::selection 색이 어두운 배경(#0d1117)과 겹쳐 선택한 글자가 안 보이므로,
|
||||
선택 영역 배경/글자색을 명시한다. (요청 스니펫 코드 + 응답 본문 패널) */
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-code::selection,
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-code ::selection,
|
||||
.swagger-ui .djb-response-grid .body-panel::selection,
|
||||
.swagger-ui .djb-response-grid .body-panel ::selection {
|
||||
background: #2f5c9e;
|
||||
color: #ffffff;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/*!
|
||||
* DjbSwaggerValidator — Execute 전에 요청 파라미터(header/query)와 요청 본문(body)을
|
||||
* OpenAPI 스키마로 검증하는 경량 JSON Schema 검증기.
|
||||
* (PoC rinjae-works/swagger-poc/js/validator.js 를 포탈용으로 이식)
|
||||
*
|
||||
* 지원 키워드: type, required, properties, items, $ref, maxLength, minLength,
|
||||
* maximum, minimum, pattern, enum
|
||||
* 지원 type: string, integer, number, object, array, boolean
|
||||
*
|
||||
* 사용:
|
||||
* var r = DjbSwaggerValidator.validateOperation(operation, parsedRequest, rootSpec);
|
||||
* if (!r.ok) console.log(r.errors); // [{ path, message, value }]
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
function resolveRef(spec, ref) {
|
||||
if (ref.indexOf("#/") !== 0) return null;
|
||||
var parts = ref.slice(2).split("/");
|
||||
var cur = spec;
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (cur == null) return null;
|
||||
cur = cur[decodeURIComponent(parts[i]).replace(/~1/g, "/").replace(/~0/g, "~")];
|
||||
}
|
||||
return cur || null;
|
||||
}
|
||||
|
||||
function deref(spec, schema, seen) {
|
||||
if (!schema || typeof schema !== "object") return schema;
|
||||
if (schema.$ref) {
|
||||
if (seen && seen.indexOf(schema.$ref) !== -1) return {};
|
||||
var next = resolveRef(spec, schema.$ref);
|
||||
var seen2 = (seen || []).concat([schema.$ref]);
|
||||
return deref(spec, next, seen2);
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
function typeMatches(value, type) {
|
||||
if (value === null || value === undefined) return false;
|
||||
switch (type) {
|
||||
case "string": return typeof value === "string";
|
||||
case "integer": return typeof value === "number" && isFinite(value) && Math.floor(value) === value;
|
||||
case "number": return typeof value === "number" && !isNaN(value);
|
||||
case "boolean": return typeof value === "boolean";
|
||||
case "array": return Array.isArray(value);
|
||||
case "object": return typeof value === "object" && !Array.isArray(value);
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
function pushErr(errors, path, message, value) {
|
||||
errors.push({ path: path || "/", message: message, value: value });
|
||||
}
|
||||
|
||||
function checkSchema(spec, schema, value, path, errors) {
|
||||
if (!schema) return;
|
||||
schema = deref(spec, schema);
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
// required 는 상위 object 단계에서 확인. 여기서는 통과.
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type && !typeMatches(value, schema.type)) {
|
||||
var actual = typeof value === "object" ? (Array.isArray(value) ? "array" : "object") : typeof value;
|
||||
pushErr(errors, path, '타입이 "' + schema.type + '" 이어야 합니다 (현재: ' + actual + ")", value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.enum) && schema.enum.indexOf(value) === -1) {
|
||||
pushErr(errors, path, "허용된 값이 아닙니다 (허용: " + schema.enum.join(", ") + ")", value);
|
||||
}
|
||||
|
||||
if (schema.type === "string" || typeof value === "string") {
|
||||
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) {
|
||||
pushErr(errors, path, "최대 " + schema.maxLength + " 자까지 허용됩니다 (현재: " + value.length + "자)", value);
|
||||
}
|
||||
if (typeof schema.minLength === "number" && value.length < schema.minLength) {
|
||||
pushErr(errors, path, "최소 " + schema.minLength + " 자 이상이어야 합니다 (현재: " + value.length + "자)", value);
|
||||
}
|
||||
if (schema.pattern) {
|
||||
try {
|
||||
if (!new RegExp(schema.pattern).test(value)) {
|
||||
pushErr(errors, path, "형식이 올바르지 않습니다 (pattern: " + schema.pattern + ")", value);
|
||||
}
|
||||
} catch (e) { /* invalid regex in spec — skip */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.type === "integer" || schema.type === "number" || typeof value === "number") {
|
||||
if (typeof schema.maximum === "number" && value > schema.maximum) {
|
||||
pushErr(errors, path, "최대값 " + schema.maximum + " 이하여야 합니다 (현재: " + value + ")", value);
|
||||
}
|
||||
if (typeof schema.minimum === "number" && value < schema.minimum) {
|
||||
pushErr(errors, path, "최소값 " + schema.minimum + " 이상이어야 합니다 (현재: " + value + ")", value);
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.type === "object" && schema.properties) {
|
||||
var requiredList = Array.isArray(schema.required) ? schema.required : [];
|
||||
requiredList.forEach(function (reqKey) {
|
||||
if (value[reqKey] === undefined || value[reqKey] === null || value[reqKey] === "") {
|
||||
pushErr(errors, path + "/" + reqKey, "필수 항목이 누락되었습니다", undefined);
|
||||
}
|
||||
});
|
||||
Object.keys(schema.properties).forEach(function (key) {
|
||||
if (value[key] !== undefined && value[key] !== null) {
|
||||
checkSchema(spec, schema.properties[key], value[key], path + "/" + key, errors);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (schema.type === "array" && schema.items && Array.isArray(value)) {
|
||||
value.forEach(function (item, idx) {
|
||||
checkSchema(spec, schema.items, item, path + "/" + idx, errors);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swagger UI 의 request 객체에서 header/query/body 를 추출해 operation 정의로 검증.
|
||||
*
|
||||
* @param {Object} operation spec.paths[path][method] 객체
|
||||
* @param {Object} req { url, method, headers, body, ... }
|
||||
* @param {Object} rootSpec $ref 해석을 위한 전체 OpenAPI 문서(json)
|
||||
* @returns {{ ok:boolean, errors:Array<{path,message,value}> }}
|
||||
*/
|
||||
function validateOperation(operation, req, rootSpec) {
|
||||
var errors = [];
|
||||
if (!operation) return { ok: true, errors: errors };
|
||||
|
||||
// 1. parameters 검증 (header / query). path 는 Swagger 가 이미 치환 → 건너뜀.
|
||||
var params = Array.isArray(operation.parameters) ? operation.parameters : [];
|
||||
for (var i = 0; i < params.length; i++) {
|
||||
var p = deref(rootSpec, params[i]);
|
||||
var where = p.in;
|
||||
var name = p.name;
|
||||
var schema = p.schema ? deref(rootSpec, p.schema) : { type: "string" };
|
||||
|
||||
var value;
|
||||
if (where === "header") {
|
||||
var headers = req.headers || {};
|
||||
var matchKey = Object.keys(headers).filter(function (k) {
|
||||
return k.toLowerCase() === String(name).toLowerCase();
|
||||
})[0];
|
||||
value = matchKey ? headers[matchKey] : undefined;
|
||||
} else if (where === "query") {
|
||||
try {
|
||||
var u = new URL(req.url, "http://_local");
|
||||
value = u.searchParams.get(name);
|
||||
} catch (e) { value = undefined; }
|
||||
} else {
|
||||
continue; // path/cookie 등은 건너뜀
|
||||
}
|
||||
|
||||
if (p.required && (value === undefined || value === "" || value === null)) {
|
||||
pushErr(errors, (where === "query" ? "query" : "header") + "[" + name + "]",
|
||||
(where === "query" ? "필수 쿼리 파라미터가" : "필수 헤더가") + " 누락되었습니다", undefined);
|
||||
continue;
|
||||
}
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
var coerced = (schema.type === "integer" || schema.type === "number") ? Number(value) : String(value);
|
||||
checkSchema(rootSpec, schema, coerced, (where === "query" ? "query" : "header") + "[" + name + "]", errors);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. requestBody 검증
|
||||
if (operation.requestBody) {
|
||||
var rb = deref(rootSpec, operation.requestBody);
|
||||
if (rb.required && (req.body === undefined || req.body === null || req.body === "")) {
|
||||
pushErr(errors, "body", "요청 본문이 비어 있습니다", undefined);
|
||||
} else if (req.body) {
|
||||
var contentTypes = Object.keys(rb.content || {});
|
||||
var ctHeader = (req.headers && (req.headers["Content-Type"] || req.headers["content-type"])) || contentTypes[0];
|
||||
var matchedCt = contentTypes.filter(function (c) {
|
||||
return ctHeader && ctHeader.toLowerCase().indexOf(c.split(";")[0].toLowerCase()) !== -1;
|
||||
})[0] || contentTypes[0];
|
||||
var bodySchema = (matchedCt && rb.content[matchedCt]) ? deref(rootSpec, rb.content[matchedCt].schema) : null;
|
||||
|
||||
var parsed = req.body;
|
||||
if (typeof parsed === "string") {
|
||||
try { parsed = JSON.parse(parsed); }
|
||||
catch (e) {
|
||||
pushErr(errors, "body", "JSON 파싱 실패: " + e.message, req.body);
|
||||
return { ok: false, errors: errors };
|
||||
}
|
||||
}
|
||||
if (bodySchema) {
|
||||
checkSchema(rootSpec, bodySchema, parsed, "body", errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, errors: errors };
|
||||
}
|
||||
|
||||
global.DjbSwaggerValidator = {
|
||||
validateOperation: validateOperation,
|
||||
_internal: { resolveRef: resolveRef, deref: deref, checkSchema: checkSchema }
|
||||
};
|
||||
})(window);
|
||||
@@ -1,7 +1,7 @@
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
overflow: -moz-scrollbars-vertical;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -12,4 +12,5 @@ html {
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
@@ -1,79 +1,6 @@
|
||||
<!doctype html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<title>Swagger UI: OAuth2 Redirect</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
'use strict';
|
||||
function run () {
|
||||
var oauth2 = window.opener.swaggerUIRedirectOauth2;
|
||||
var sentState = oauth2.state;
|
||||
var redirectUrl = oauth2.redirectUrl;
|
||||
var isValid, qp, arr;
|
||||
|
||||
if (/code|token|error/.test(window.location.hash)) {
|
||||
qp = window.location.hash.substring(1).replace('?', '&');
|
||||
} else {
|
||||
qp = location.search.substring(1);
|
||||
}
|
||||
|
||||
arr = qp.split("&");
|
||||
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';});
|
||||
qp = qp ? JSON.parse('{' + arr.join() + '}',
|
||||
function (key, value) {
|
||||
return key === "" ? value : decodeURIComponent(value);
|
||||
}
|
||||
) : {};
|
||||
|
||||
isValid = qp.state === sentState;
|
||||
|
||||
if ((
|
||||
oauth2.auth.schema.get("flow") === "accessCode" ||
|
||||
oauth2.auth.schema.get("flow") === "authorizationCode" ||
|
||||
oauth2.auth.schema.get("flow") === "authorization_code"
|
||||
) && !oauth2.auth.code) {
|
||||
if (!isValid) {
|
||||
oauth2.errCb({
|
||||
authId: oauth2.auth.name,
|
||||
source: "auth",
|
||||
level: "warning",
|
||||
message: "Authorization may be unsafe, passed state was changed in server. The passed state wasn't returned from auth server."
|
||||
});
|
||||
}
|
||||
|
||||
if (qp.code) {
|
||||
delete oauth2.state;
|
||||
oauth2.auth.code = qp.code;
|
||||
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
|
||||
} else {
|
||||
let oauthErrorMsg;
|
||||
if (qp.error) {
|
||||
oauthErrorMsg = "["+qp.error+"]: " +
|
||||
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
|
||||
(qp.error_uri ? "More info: "+qp.error_uri : "");
|
||||
}
|
||||
|
||||
oauth2.errCb({
|
||||
authId: oauth2.auth.name,
|
||||
source: "auth",
|
||||
level: "error",
|
||||
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server."
|
||||
});
|
||||
}
|
||||
} else {
|
||||
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
|
||||
if (document.readyState !== 'loading') {
|
||||
run();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
run();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script src="oauth2-redirect.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -459,11 +459,11 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
height: 40px;
|
||||
padding: 0 $spacing-sm;
|
||||
height: 30px;
|
||||
white-space: nowrap;
|
||||
border-radius: $border-radius-md;
|
||||
font-size: $font-size-base;
|
||||
border-radius: $border-radius-sm;
|
||||
font-size: 13px;
|
||||
font-weight: $font-weight-regular;
|
||||
color: #000000;
|
||||
border: none;
|
||||
@@ -504,6 +504,15 @@
|
||||
background-color: darken(#CDD4F0, 5%);
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background-color: #F8D7DA;
|
||||
color: #B02A37;
|
||||
|
||||
&:hover {
|
||||
background-color: darken(#F8D7DA, 5%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -602,6 +602,130 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Testbed 앱 선택 패널 (DJPGPT0001)
|
||||
.testbed-app-panel {
|
||||
margin: $spacing-sm 0 $spacing-lg;
|
||||
padding: $spacing-lg;
|
||||
background: $white;
|
||||
border: 1px solid $border-gray;
|
||||
border-radius: $border-radius-lg;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
.testbed-app-panel__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs $spacing-sm;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.testbed-app-panel__title {
|
||||
position: relative;
|
||||
padding-left: $spacing-md;
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $text-dark;
|
||||
letter-spacing: -0.01em;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 15px;
|
||||
background: $primary-blue;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.testbed-app-panel__desc {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-gray;
|
||||
}
|
||||
|
||||
.testbed-app-field {
|
||||
position: relative;
|
||||
max-width: 380px;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: $spacing-md;
|
||||
top: 50%;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
margin-top: -6px;
|
||||
border-right: 2px solid $text-gray;
|
||||
border-bottom: 2px solid $text-gray;
|
||||
transform: rotate(45deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
#apps {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
padding: 0 42px 0 $input-padding-x;
|
||||
font-family: $font-family-primary;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-dark;
|
||||
background: $gray-bg;
|
||||
border: 1px solid $border-gray;
|
||||
border-radius: $border-radius-md;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
cursor: pointer;
|
||||
transition: $transition-fast;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $text-light;
|
||||
background: $white;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background: $white;
|
||||
border-color: $primary-blue;
|
||||
box-shadow: 0 0 0 3px rgba($primary-blue, 0.14);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: $text-light;
|
||||
background: $gray-bg;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.testbed-app-notice {
|
||||
margin: $spacing-md 0 0;
|
||||
padding: 11px $spacing-md;
|
||||
font-size: $font-size-sm;
|
||||
line-height: $line-height-normal;
|
||||
color: $text-gray;
|
||||
background: $light-bg;
|
||||
border: 1px solid rgba($primary-blue, 0.18);
|
||||
border-left: 3px solid $primary-blue;
|
||||
border-radius: $border-radius-sm;
|
||||
|
||||
&::before {
|
||||
content: "ⓘ ";
|
||||
color: $primary-blue;
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
padding: $spacing-md;
|
||||
|
||||
.testbed-app-field {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// API Overview Card (Flat Style)
|
||||
.api-overview-card {
|
||||
background: transparent;
|
||||
|
||||
@@ -876,6 +876,26 @@
|
||||
// 모바일에서는 2개 버튼만 표시되는 경우 (이전, 수정)
|
||||
display: none;
|
||||
}
|
||||
|
||||
// 모바일: 앱 수정 마법사 UI 표현이 어려워 버튼 숨기고 PC 안내 노출
|
||||
&.app-modify-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PC 환경 안내 문구 - 모바일에서만 노출 (앱 수정)
|
||||
.app-modify-pc-only-message {
|
||||
display: none;
|
||||
margin: 0;
|
||||
padding: 0 20px 24px;
|
||||
color: $text-gray;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +194,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Expected Completion Date (pending requests only)
|
||||
.app-list-expect-date {
|
||||
margin-left: auto;
|
||||
font-family: $font-family-primary;
|
||||
font-size: 14px;
|
||||
font-weight: $font-weight-regular;
|
||||
color: #6e7780;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
// Figma 모바일: 12px
|
||||
@include respond-to('sm') {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// App Description
|
||||
.app-list-description {
|
||||
font-family: $font-family-primary;
|
||||
@@ -246,4 +262,23 @@
|
||||
font-size: 14px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
// 모바일: 앱 생성 마법사 UI 표현이 어려워 버튼 숨기고 PC 안내 노출
|
||||
@include respond-to('sm') {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// PC 환경 안내 문구 - 모바일에서만 노출
|
||||
.app-create-pc-only-message {
|
||||
display: none;
|
||||
margin: 0;
|
||||
color: $text-gray;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
|
||||
@include respond-to('sm') {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +370,7 @@
|
||||
// IP Input Row - Step 1 Style
|
||||
.ip-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 21px;
|
||||
|
||||
.ip-input {
|
||||
@@ -398,7 +399,7 @@
|
||||
}
|
||||
|
||||
.btn-add-ip {
|
||||
width: auto;
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
background-color: #3ba4ed;
|
||||
@@ -410,20 +411,23 @@
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background-color: #2b94dd;
|
||||
}
|
||||
|
||||
@include respond-to('md') {
|
||||
width: auto;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
// Figma 모바일 (1290-3137): 89px × 40px, border-radius 8px, 12px bold
|
||||
@include respond-to('sm') {
|
||||
width: auto;
|
||||
width: 89px;
|
||||
height: 36px;
|
||||
font-size: 13px;
|
||||
border-radius: 8px;
|
||||
@@ -446,6 +450,7 @@
|
||||
|
||||
.ip-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 21px;
|
||||
padding: 0;
|
||||
border-bottom: none;
|
||||
@@ -474,7 +479,6 @@
|
||||
background-color: $white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 20px;
|
||||
color: $text-dark;
|
||||
|
||||
// Figma 모바일: 40px 높이, 14px 폰트
|
||||
@@ -488,25 +492,26 @@
|
||||
}
|
||||
|
||||
.btn-remove-ip {
|
||||
width: 158px !important;
|
||||
height: 60px !important;
|
||||
width: 100px !important;
|
||||
height: 40px !important;
|
||||
background-color: #e5e7eb !important;
|
||||
color: #5f666c !important;
|
||||
border: none !important;
|
||||
border-radius: $border-radius-lg !important;
|
||||
font-size: 20px !important;
|
||||
font-weight: $font-weight-bold;
|
||||
border-radius: $border-radius-md !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: $font-weight-semibold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: center;
|
||||
|
||||
@include respond-to('md') {
|
||||
width: 100% !important;
|
||||
font-size: 16px !important;
|
||||
height: 50px !important;
|
||||
font-size: 14px !important;
|
||||
height: 38px !important;
|
||||
}
|
||||
|
||||
// Figma 모바일 (1290-3137): 89px × 40px, border-radius 8px, 12px bold
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
.signup-selection-page {
|
||||
min-height: calc(100vh - 140px); // Account for header and footer
|
||||
min-height: calc(100vh - 380px); // Account for header and footer
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -86,6 +86,15 @@
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
padding: 0;
|
||||
// 탭이 가로폭을 넘어갈 경우 가로 스크롤 허용
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none; // Firefox
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none; // Chrome/Safari
|
||||
}
|
||||
}
|
||||
|
||||
.tab-link {
|
||||
@@ -107,8 +116,11 @@
|
||||
border-radius: 12px 12px 0 0;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
padding: $spacing-md $spacing-md;
|
||||
font-size: $font-size-base;
|
||||
// 글자 한 줄 유지 + 글자 크기/높이 축소
|
||||
flex: 1 0 auto;
|
||||
white-space: nowrap;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
font-size: $font-size-sm;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
@@ -130,6 +142,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state (해당 약관이 아직 시드되지 않은 경우)
|
||||
.terms-empty {
|
||||
padding: $spacing-xl $spacing-lg;
|
||||
text-align: center;
|
||||
color: #8c959f;
|
||||
font-size: $font-size-base;
|
||||
}
|
||||
|
||||
// Version Selector (Figma: 검색창 986-2208)
|
||||
.terms-selector {
|
||||
padding: $spacing-lg;
|
||||
|
||||
@@ -101,16 +101,15 @@
|
||||
// Current user badge (used in actions column)
|
||||
.user-current-badge {
|
||||
display: inline-flex;
|
||||
height: 40px;
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
padding: 0 $spacing-sm;
|
||||
background-color: rgba($accent-purple, 0.1);
|
||||
color: $accent-purple;
|
||||
border-radius: $border-radius-sm;
|
||||
font-size: $font-size-sm;
|
||||
font-size: 13px;
|
||||
font-weight: $font-weight-medium;
|
||||
min-width: 116px;
|
||||
|
||||
// 모바일에서 숨김
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
@@ -699,25 +698,25 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
// 제목 컬럼 - flex 1
|
||||
// 제목 컬럼 - flex 1 (인라인 min-width:200px 무력화로 모바일 축소 허용)
|
||||
&:nth-child(2) {
|
||||
flex: 1 !important;
|
||||
min-width: 100px;
|
||||
flex: 1 1 0 !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
// 작성자 컬럼 - 50px, 가운데 정렬
|
||||
// 작성자 컬럼 - 60px, 가운데 정렬
|
||||
&:nth-child(3) {
|
||||
width: 50px !important;
|
||||
min-width: 50px;
|
||||
flex: none;
|
||||
width: 60px !important;
|
||||
min-width: 60px !important;
|
||||
flex: none !important;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// 처리상태 컬럼 - 65px, 가운데 정렬
|
||||
// 처리상태 컬럼 - 78px, 가운데 정렬
|
||||
&:nth-child(4) {
|
||||
width: 65px !important;
|
||||
min-width: 65px;
|
||||
flex: none;
|
||||
width: 78px !important;
|
||||
min-width: 78px !important;
|
||||
flex: none !important;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -736,11 +735,12 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
// 제목 컬럼 - flex 1
|
||||
// 제목 컬럼 - flex 1 (인라인 min-width:200px 무력화로 모바일 축소 허용)
|
||||
&:nth-child(2),
|
||||
&.row-cell--title {
|
||||
flex: 1 !important;
|
||||
min-width: 100px;
|
||||
flex: 1 1 0 !important;
|
||||
min-width: 0 !important;
|
||||
overflow: hidden;
|
||||
justify-content: flex-start;
|
||||
|
||||
.notice-title-link {
|
||||
@@ -773,20 +773,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 작성자 컬럼 - 50px, 가운데 정렬
|
||||
// 작성자 컬럼 - 60px, 가운데 정렬 (마스킹 이름 넘침 방지)
|
||||
&:nth-child(3) {
|
||||
width: 50px !important;
|
||||
min-width: 50px;
|
||||
flex: none;
|
||||
width: 60px !important;
|
||||
min-width: 60px !important;
|
||||
flex: none !important;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 처리상태 컬럼 - 65px, 가운데 정렬
|
||||
// 처리상태 컬럼 - 78px, 가운데 정렬
|
||||
&:nth-child(4) {
|
||||
width: 65px !important;
|
||||
min-width: 65px;
|
||||
flex: none;
|
||||
width: 78px !important;
|
||||
min-width: 78px !important;
|
||||
flex: none !important;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
|
||||
@@ -1054,6 +1057,19 @@
|
||||
& + .dropdown-item {
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: #B02A37;
|
||||
|
||||
&:hover {
|
||||
background: #fdeaec;
|
||||
color: #B02A37;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #f8d7da;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +147,21 @@
|
||||
<div class="tab-content" id="testbed-tab">
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/swagger-ui.css}"/>
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/index.css}"/>
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/djb-swagger-testbed.css}"/>
|
||||
|
||||
<!-- DJPGPT0001: 앱(인증키) 선택 → 인증 정보 자동 주입 -->
|
||||
<div class="testbed-app-panel" id="appsWrap">
|
||||
<div class="testbed-app-panel__head">
|
||||
<span class="testbed-app-panel__title">앱 선택</span>
|
||||
<span class="testbed-app-panel__desc">테스트할 앱(인증키)을 선택하면 인증 정보가 자동 입력됩니다.</span>
|
||||
</div>
|
||||
<div class="testbed-app-field">
|
||||
<select id="apps">
|
||||
<option value="">앱을 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="testbed-app-notice" id="appsNotice" style="display:none;"></p>
|
||||
</div>
|
||||
|
||||
<div id="swagger-ui"></div>
|
||||
</div>
|
||||
@@ -160,6 +175,8 @@
|
||||
</body>
|
||||
|
||||
<th:block layout:fragment="contentScript">
|
||||
<!-- DJB Swagger UI 한글화 패치 (MutationObserver DOM patch) -->
|
||||
<script th:src="@{/plugins/swaggerUI/djb-swagger-i18n.js}" charset="UTF-8"></script>
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// DOM Elements
|
||||
@@ -284,7 +301,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const swaggerUrl = `/api/apis/${apiId}/swagger.json`;
|
||||
const swaggerUrl = `/djb/testbed/apis/${apiId}/swagger.json`;
|
||||
|
||||
// Load Swagger UI scripts dynamically
|
||||
const loadScript = (src) => {
|
||||
@@ -305,63 +322,29 @@
|
||||
// Load code template script
|
||||
return loadScript(/*[[@{/js/code_template.js}]]*/ '');
|
||||
}).then(() => {
|
||||
// Initialize Swagger UI
|
||||
const sampleCodeBaseUrl = /*[[@{/api/sample_code/}]]*/ '/api/sample_code/';
|
||||
const generateSnippet = (parsedRequest, language) => {
|
||||
let result;
|
||||
try {
|
||||
$.ajax({
|
||||
url: sampleCodeBaseUrl + language,
|
||||
type: 'POST',
|
||||
async: false,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ ''
|
||||
},
|
||||
data: JSON.stringify(parsedRequest),
|
||||
success: function(data) {
|
||||
result = data;
|
||||
},
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
console.error('Error:', errorThrown);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const SnippetGeneratorPlugin = {
|
||||
fn: {
|
||||
requestSnippetGenerator_csharp: (request) => {
|
||||
var parsedRequest = parseSwaggerRequest(request);
|
||||
return generateSnippet(parsedRequest, 'csharp');
|
||||
},
|
||||
requestSnippetGenerator_ecma5: (request) => {
|
||||
var parsedRequest = parseSwaggerRequest(request);
|
||||
return generateSnippet(parsedRequest, 'ecma5');
|
||||
},
|
||||
requestSnippetGenerator_java: (request) => {
|
||||
var parsedRequest = parseSwaggerRequest(request);
|
||||
return generateSnippet(parsedRequest, 'java');
|
||||
},
|
||||
requestSnippetGenerator_python: (request) => {
|
||||
var parsedRequest = parseSwaggerRequest(request);
|
||||
return generateSnippet(parsedRequest, 'python');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// DJB 요청 스니펫(7종) / 상시 스니펫 패널 / 커스텀 응답 그리드 스크립트
|
||||
return Promise.all([
|
||||
loadScript(/*[[@{/plugins/swaggerUI/djb-swagger-snippets.js}]]*/ ''),
|
||||
loadScript(/*[[@{/plugins/swaggerUI/djb-swagger-snippet-panel.js}]]*/ ''),
|
||||
loadScript(/*[[@{/plugins/swaggerUI/djb-swagger-validator.js}]]*/ ''),
|
||||
loadScript(/*[[@{/plugins/swaggerUI/djb-swagger-response.js}]]*/ '')
|
||||
]);
|
||||
}).then(() => {
|
||||
// Clear existing Swagger UI
|
||||
document.getElementById('swagger-ui').innerHTML = '';
|
||||
|
||||
// 테스트베드 프록시 사용 여부(djb.gateway.use-proxy). N 이면 브라우저에서 대상으로 직접 호출.
|
||||
window.__djbUseProxy = /*[[${@djbTestbedGatewayProperty.useProxy()}]]*/ true;
|
||||
// 토큰 발급 프록시 사용 여부(djb.gateway.token-use-proxy) — API 호출과 별개.
|
||||
window.__djbTokenUseProxy = /*[[${@djbTestbedGatewayProperty.tokenUseProxy()}]]*/ true;
|
||||
|
||||
// Initialize Swagger UI
|
||||
const ui = SwaggerUIBundle({
|
||||
url: swaggerUrl,
|
||||
dom_id: '#swagger-ui',
|
||||
requestInterceptor: function(req) {
|
||||
if (!req.loadSpec) {
|
||||
// 프록시 사용 시에만 /api/call-api 로 우회. 직접호출 모드(false)면 req.url(스펙 서버+경로) 그대로 호출.
|
||||
if (!req.loadSpec && window.__djbUseProxy !== false) {
|
||||
const urlPath = new URL(req.url).pathname;
|
||||
const method = req.method.toLowerCase();
|
||||
|
||||
@@ -387,48 +370,158 @@
|
||||
}
|
||||
return req;
|
||||
},
|
||||
responseInterceptor: function(res) {
|
||||
// 커스텀 응답 그리드에 렌더 (기본 응답영역은 CSS 로 숨김)
|
||||
try { window.DjbSwaggerResponse.renderResponse(res); } catch (e) { console.error(e); }
|
||||
return res;
|
||||
},
|
||||
onComplete: function() {
|
||||
// 단일 API 페이지 — 상시 요청 스니펫 패널 마운트
|
||||
setTimeout(function() { try { window.DjbSwaggerSnippetPanel.mount({ rootSel: '#swagger-ui' }); } catch (e) { console.error(e); } }, 50);
|
||||
},
|
||||
showMutatedRequest: false,
|
||||
validatorUrl: '',
|
||||
deepLinking: true,
|
||||
// 항상 1개 API 노출 → operation 을 펼친 상태로, Try it out 활성 기본
|
||||
docExpansion: 'full',
|
||||
defaultModelsExpandDepth: -1,
|
||||
defaultModelExpandDepth: 5,
|
||||
tryItOutEnabled: true,
|
||||
displayRequestDuration: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset.slice(1)
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl,
|
||||
SnippetGeneratorPlugin
|
||||
window.SnippetGeneratorPlugin
|
||||
],
|
||||
layout: 'StandaloneLayout',
|
||||
requestSnippetsEnabled: true,
|
||||
requestSnippets: {
|
||||
generators: {
|
||||
'csharp': {
|
||||
title: 'C#',
|
||||
syntax: 'csharp'
|
||||
},
|
||||
'ecma5': {
|
||||
title: 'ECMA5',
|
||||
syntax: 'javascript'
|
||||
},
|
||||
'java': {
|
||||
title: 'Java',
|
||||
syntax: 'java'
|
||||
},
|
||||
'python': {
|
||||
title: 'Python',
|
||||
syntax: 'python'
|
||||
}
|
||||
'curl_bash': { title: 'cURL (Bash)', syntax: 'bash' },
|
||||
'curl_powershell': { title: 'cURL (PowerShell)', syntax: 'powershell' },
|
||||
'curl_cmd': { title: 'cURL (CMD)', syntax: 'bash' },
|
||||
'csharp': { title: 'C#', syntax: 'csharp' },
|
||||
'ecma5': { title: 'ECMA5 (JavaScript)', syntax: 'javascript' },
|
||||
'java': { title: 'Java', syntax: 'java' },
|
||||
'python': { title: 'Python', syntax: 'python' }
|
||||
},
|
||||
defaultExpanded: true,
|
||||
languages: null
|
||||
}
|
||||
});
|
||||
window.ui = ui;
|
||||
// Execute 클릭 캡처(응답 그리드 대상/타이머) 등록 — #swagger-ui 는 재초기화돼도 유지됨
|
||||
window.DjbSwaggerResponse.attach('#swagger-ui');
|
||||
// 앱이 이미 선택돼 있으면 스펙 로드 후 재주입
|
||||
if (appsSelect && appsSelect.value) {
|
||||
setTimeout(function () { onAppSelected(appsSelect.value); }, 300);
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('Failed to load Swagger UI:', error);
|
||||
document.getElementById('swagger-ui').innerHTML = '<p>Swagger UI를 불러오는 중 오류가 발생했습니다.</p>';
|
||||
});
|
||||
}
|
||||
|
||||
// ===== DJPGPT0001: 앱 인증 정보 자동 주입 =====
|
||||
const DEFAULT_TOKEN_API_ID = 'default-token-api-spec';
|
||||
const appsSelect = document.getElementById('apps');
|
||||
|
||||
function eligibilityMessage(reason) {
|
||||
switch (reason) {
|
||||
case 'ANONYMOUS': return '로그인 후 본인 앱의 인증 정보를 사용할 수 있습니다.';
|
||||
case 'INDIVIDUAL_USER': return '개인사용자는 앱 인증 정보를 사용할 수 없습니다. 법인 계정으로 이용해 주세요.';
|
||||
case 'NO_CREDENTIAL': return '사용 가능한 앱(인증키)이 없습니다. 인증 키 관리에서 앱을 생성해 주세요.';
|
||||
default: return '앱 인증 정보를 사용할 수 없습니다.';
|
||||
}
|
||||
}
|
||||
|
||||
function loadTestbedContext() {
|
||||
fetch(/*[[@{/djb/testbed/auth/context}]]*/ '/djb/testbed/auth/context')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (ctx) {
|
||||
window.__djbGateway = ctx.gateway || {};
|
||||
if (!appsSelect) return;
|
||||
const notice = document.getElementById('appsNotice');
|
||||
if (!ctx.eligible) {
|
||||
appsSelect.disabled = true;
|
||||
if (notice) { notice.style.display = 'block'; notice.textContent = eligibilityMessage(ctx.reason); }
|
||||
return;
|
||||
}
|
||||
appsSelect.disabled = false;
|
||||
if (notice) notice.style.display = 'none';
|
||||
(ctx.credentials || []).forEach(function (c) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c.clientId;
|
||||
opt.textContent = c.clientName ? (c.clientName + ' (' + c.clientId + ')') : c.clientId;
|
||||
appsSelect.appendChild(opt);
|
||||
});
|
||||
})
|
||||
.catch(function (e) { console.error('테스트베드 컨텍스트 로드 실패', e); });
|
||||
}
|
||||
|
||||
function fetchOAuthToken(clientId, clientSecret, gw) {
|
||||
const body = 'grant_type=client_credentials'
|
||||
+ '&client_id=' + encodeURIComponent(clientId)
|
||||
+ '&client_secret=' + encodeURIComponent(clientSecret)
|
||||
+ '&scope=api';
|
||||
// 토큰 발급 프록시 여부(djb.gateway.token-use-proxy). false 면 토큰 URL 로 브라우저 직접 호출.
|
||||
var direct = (window.__djbTokenUseProxy === false);
|
||||
var url = direct ? gw.tokenUrl : window.location.origin + (/*[[@{/api/call-api}]]*/ '/api/call-api');
|
||||
var headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
||||
if (!direct) {
|
||||
headers['original-url'] = gw.tokenUrl;
|
||||
headers['X-XSRF-TOKEN'] = /*[[${_csrf.token}]]*/ '';
|
||||
}
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: body
|
||||
}).then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) { return data ? data.access_token : null; });
|
||||
}
|
||||
|
||||
function authorizeApp(secret) {
|
||||
const gw = window.__djbGateway || {};
|
||||
if (!window.ui) return;
|
||||
if (secret.authType === 'OAUTH') {
|
||||
fetchOAuthToken(secret.clientId, secret.clientSecret, gw).then(function (token) {
|
||||
if (!token) return;
|
||||
window.ui.authActions.authorize({
|
||||
djbOAuth: {
|
||||
name: 'djbOAuth',
|
||||
schema: { type: 'apiKey', in: 'header', name: gw.oauthTokenHeader },
|
||||
value: 'Bearer ' + token
|
||||
}
|
||||
});
|
||||
});
|
||||
} else if (secret.authType === 'API_KEY') {
|
||||
window.ui.authActions.authorize({
|
||||
djbApiKey: {
|
||||
name: 'djbApiKey',
|
||||
schema: { type: 'apiKey', in: 'header', name: gw.apiKeyHeader },
|
||||
value: secret.clientSecret
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onAppSelected(clientId) {
|
||||
if (!clientId) return;
|
||||
if (!currentApiId || currentApiId === 'default' || currentApiId === DEFAULT_TOKEN_API_ID) return;
|
||||
fetch((/*[[@{/djb/testbed/auth/credentials/}]]*/ '/djb/testbed/auth/credentials/')
|
||||
+ encodeURIComponent(clientId) + '/secret?apiId=' + encodeURIComponent(currentApiId))
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (secret) { if (secret) authorizeApp(secret); })
|
||||
.catch(function (e) { console.error('앱 인증정보 주입 실패', e); });
|
||||
}
|
||||
|
||||
if (appsSelect) {
|
||||
appsSelect.addEventListener('change', function () { onAppSelected(this.value); });
|
||||
}
|
||||
loadTestbedContext();
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user