클라이언트 생성/수정 흐름 및 관련 UI 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- 클라이언트 등록/수정 URL 구조 리팩토링 (myapikey → clients)
- 로그인 직후 클라이언트 미보유 사용자 대상 추가 안내 팝업
- 클라이언트 Secret 조회 및 관련 2FA 단계 로직 개선
This commit is contained in:
Rinjae
2026-07-29 19:25:10 +09:00
parent c02a5ff047
commit 02eb81af67
37 changed files with 535 additions and 197 deletions
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.apps.apis.filter;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import java.io.BufferedReader;
import java.io.DataOutputStream;
@@ -37,13 +38,18 @@ public class APISender {
public String requestPost(String uri, String requestBody) throws IOException {
if (logger.isDebugEnabled()) {
logger.debug("APISender POST(json) 요청 - uri={}, bodyLen={}, body={}",
uri, requestBody == null ? 0 : requestBody.length(), StringMaskingUtil.maskFormBody(requestBody));
}
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
String response = getResponse(connection);
connection.disconnect();
if (logger.isDebugEnabled()) {
logger.debug(response);
logger.debug("APISender POST(json) 응답 - uri={}, response={}", uri, response);
}
return response;
}
@@ -85,11 +91,15 @@ public class APISender {
}
connection.setDoOutput(true);
if (logger.isDebugEnabled()) {
logger.debug("APISender GET 요청 - uri={}", appendUriAndParams(uri, params));
}
String response = getResponse(connection);
connection.disconnect();
if (logger.isDebugEnabled()) {
logger.debug(response);
logger.debug("APISender GET 응답 - uri={}, response={}", uri, response);
}
return response;
}
@@ -110,6 +120,12 @@ public class APISender {
}
connection.setDoOutput(true);
if (logger.isDebugEnabled()) {
// client_secret 등 민감 파라미터는 마스킹. body 비어있으면 상위에서 본문 전송 유실.
logger.debug("APISender POST 요청 - uri={}, bodyLen={}, body={}",
uri, requestBody == null ? 0 : requestBody.length(), StringMaskingUtil.maskFormBody(requestBody));
}
try (DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream())) {
byte[] requestBodyBytes = requestBody.getBytes(StandardCharsets.UTF_8);
outputStream.write(requestBodyBytes);
@@ -120,7 +136,7 @@ public class APISender {
connection.disconnect();
if (logger.isDebugEnabled()) {
logger.debug(response);
logger.debug("APISender POST 응답 - uri={}, response={}", uri, response);
}
return response;
}
@@ -4,12 +4,13 @@ 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.common.util.StringMaskingUtil;
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;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
@@ -72,6 +73,8 @@ public class ApiTesterFilter implements Filter {
String auditId = ApiTesterAuditLogger.newAuditId();
long auditStart = System.currentTimeMillis();
String auditType = "-";
// 실제 프록시 호출 대상 URL (mock 은 mockUrl, 토큰 GW 는 base-url+token-path 로 original-url 과 다를 수 있음) — 오류 로그용
String proxyTarget = null;
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
if (url == null || url.trim().isEmpty()) {
@@ -86,22 +89,24 @@ public class ApiTesterFilter implements Filter {
// 반환하기 위해 try 로 감싼다.
try {
// 게이트웨이 모드에 따라 OAuth 토큰 발급 요청을 mock 또는 실 게이트웨이 forward 로 분기 (DJPGPT0001)
// 토큰 발급 분기는 전역 게이트웨이 모드가 아니라 "요청 URL 경로"로 판단한다 (API 별 responseType 기반).
// - mock API → 프론트가 포탈 mock 토큰 경로(/api/v1/oauth/token)로 요청 → 즉시 mock 토큰 발급
// - gw API → 프론트가 실 GW 토큰 경로(token-path)로 요청 → 실 게이트웨이 forward
DjbTestbedGatewayProperty gatewayProperty = ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class);
DjbGatewayMode gatewayMode = gatewayProperty.resolveGatewayMode();
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|| url.contains(gatewayProperty.tokenPath());
boolean mockTokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH);
boolean tokenRequest = mockTokenRequest || url.contains(gatewayProperty.tokenPath());
// 본문은 한 번만 읽어 프록시 forward 와 감사 로그에 함께 사용 (GET 이면 빈 문자열)
String requestBody = readBody(httpServletRequest);
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url, gatewayMode.name(), tokenRequest, requestBody);
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url,
mockTokenRequest ? "MOCK_TOKEN" : "GW", tokenRequest, requestBody);
if (tokenRequest) {
String body = requestBody;
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
if (mockTokenRequest) {
auditType = "TOKEN_MOCK";
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
// mock 응답유형 API: 고정 mock 토큰 즉시 발급 (Secret 검증 없음)
Map<String, String> params = new HashMap<>();
String[] pairs = body.split("&");
for (String pair : pairs) {
@@ -130,6 +135,12 @@ public class ApiTesterFilter implements Filter {
headers.put("Content-Type", "application/x-www-form-urlencoded");
headers.put("Accept", "application/json");
String target = gatewayProperty.baseUrl() + gatewayProperty.tokenPath();
proxyTarget = target;
if (logger.isDebugEnabled()) {
// client_secret 은 마스킹. body 가 비면 프론트→프록시 전송 유실, client_id 없으면 GW "client not found" 원인.
logger.debug("TOKEN_GW forward - auditId={}, target={}, bodyLen={}, body={}",
auditId, target, body.length(), StringMaskingUtil.maskFormBody(body));
}
String tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
response.setContentType("application/json");
@@ -191,6 +202,7 @@ public class ApiTesterFilter implements Filter {
paramMap = extractQueryParams(url);
}
proxyTarget = targetUri;
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
String responseStr;
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
@@ -204,17 +216,23 @@ public class ApiTesterFilter implements Filter {
} catch (java.net.SocketTimeoutException e) {
// 연결/응답 타임아웃 (djb.gateway.timeout 초과)
logger.warn("테스트베드 프록시 타임아웃: {}", e.getMessage());
logger.warn("테스트베드 프록시 타임아웃 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage());
writeJson(response, HttpServletResponse.SC_GATEWAY_TIMEOUT,
"{\"error\":\"게이트웨이 응답 시간 초과(timeout)\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} catch (IOException e) {
// 연결 실패 등 네트워크 오류
logger.error("테스트베드 프록시 호출 실패", e);
// 연결 실패 등 네트워크 오류 (ConnectException: 대상 다운/포트 닫힘, UnknownHostException: 주소 오기입 등)
logger.error("테스트베드 프록시 호출 실패 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage(), e);
writeJson(response, HttpServletResponse.SC_BAD_GATEWAY,
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getClass().getSimpleName() + ": " + e.getMessage()) + "\"}");
} catch (Exception e) {
// 그 외 예기치 못한 오류도 JSON 으로 반환
logger.error("테스트베드 프록시 처리 오류", e);
logger.error("테스트베드 프록시 처리 오류 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage(), e);
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} finally {
@@ -223,7 +241,14 @@ public class ApiTesterFilter implements Filter {
}
}
/** 요청 본문 전체를 문자열로 읽는다. */
/**
* 요청 본문 전체를 문자열로 읽는다.
*
* <p>form-urlencoded 요청에서 상위 필터(XSS/CSRF/Multipart 등)가 이미 {@code getParameter*} 로
* 본문 스트림을 소비했으면 {@code getReader()} 는 빈 문자열을 반환한다. 이 경우 토큰 발급 본문
* (grant_type/client_id/client_secret/scope)이 게이트웨이로 전달되지 않아 "client not found" 로
* 실패하므로, 파싱된 파라미터 맵으로 본문을 재구성해 복원한다.</p>
*/
private String readBody(HttpServletRequest request) throws IOException {
StringBuilder sb = new StringBuilder();
BufferedReader reader = request.getReader();
@@ -231,6 +256,39 @@ public class ApiTesterFilter implements Filter {
while ((line = reader.readLine()) != null) {
sb.append(line);
}
if (sb.length() == 0 && isFormUrlEncoded(request)) {
String rebuilt = rebuildFormBodyFromParams(request);
if (!rebuilt.isEmpty()) {
logger.debug("요청 본문이 비어 파라미터 맵으로 재구성 - body={}", StringMaskingUtil.maskFormBody(rebuilt));
return rebuilt;
}
}
return sb.toString();
}
/** Content-Type 이 application/x-www-form-urlencoded 계열인지. */
private boolean isFormUrlEncoded(HttpServletRequest request) {
String contentType = request.getContentType();
return contentType != null && contentType.toLowerCase().contains("application/x-www-form-urlencoded");
}
/** 파싱된 파라미터 맵을 form-urlencoded 본문 문자열로 재구성 (본문 스트림이 이미 소비된 경우 복원용). */
private String rebuildFormBodyFromParams(HttpServletRequest request) {
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
for (String value : entry.getValue()) {
if (sb.length() > 0) {
sb.append('&');
}
try {
sb.append(URLEncoder.encode(entry.getKey(), "UTF-8"))
.append('=')
.append(URLEncoder.encode(value == null ? "" : value, "UTF-8"));
} catch (java.io.UnsupportedEncodingException e) {
sb.append(entry.getKey()).append('=').append(value == null ? "" : value);
}
}
}
return sb.toString();
}
@@ -52,7 +52,7 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Slf4j
@Controller
@RequestMapping("/myapikey")
@RequestMapping("/clients")
@RequiredArgsConstructor
@SessionAttributes({"apiKeyRegistration", "apiKeyModification"})
public class MyAppController {
@@ -118,14 +118,14 @@ public class MyAppController {
@Secured("ROLE_APP")
public ModelAndView appRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
if (id == null) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
AppRequestDTO appRequest = appServiceFacade.getAppRequestById(id, user.getPortalOrg());
if (appRequest == null) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
// API 목록 조회 및 설정
@@ -156,14 +156,14 @@ public class MyAppController {
@Secured("ROLE_APP")
public ModelAndView credentialDetail(@RequestParam(value = "id", required = false) String id, Model model) {
if (id == null) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), id);
if (apiKey == null) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
// API 목록에 서비스 정보 추가
@@ -323,10 +323,12 @@ public class MyAppController {
}
/**
* Client Secret을 비밀번호 확인 후 1회 노출하고 즉시 DB에서 물리 삭제합니다.
* Client Secret을 1회 노출하고 즉시 DB에서 물리 삭제합니다.
* 본인 확인은 step-up 2FA({@link StepUpProtectedPaths#REVEAL_SECRET} 인터셉터 가드)가 담당하며,
* 통과권 없이 진입하면 401(stepUpRequired) 로 차단됩니다.
* 보안 정책상 비밀정보는 최초 1회만 제공됩니다.
*
* @param requestData clientId, password 포함
* @param requestData clientId 포함
* @return {success, secret} / {success:false, alreadyRevealed:true} / {success:false, message}
*/
@PostMapping("/credential/reveal-secret")
@@ -336,7 +338,6 @@ public class MyAppController {
Map<String, Object> result = new java.util.HashMap<>();
String clientId = requestData.get("clientId");
String password = requestData.get("password");
if (clientId == null || clientId.trim().isEmpty()) {
result.put("success", false);
@@ -346,14 +347,7 @@ public class MyAppController {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
// 1. 본인 확인 (비밀번호)
if (!appServiceFacade.verifyUserPassword(user, password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
}
// 2. 소유권 확인 + 1회 노출 + 물리 삭제
// 소유권 확인 + 1회 노출 + 물리 삭제 (본인 확인은 step-up 2FA 인터셉터가 선행)
try {
String secret = appServiceFacade.revealAndDeleteClientSecret(user.getPortalOrg().getId(), clientId);
if (secret == null) {
@@ -435,7 +429,7 @@ public class MyAppController {
@Secured("ROLE_API_KEY_REQUEST_VIEW")
public ModelAndView apiRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
if (id == null) {
return new ModelAndView("redirect:/myapikey/api_key_request/history");
return new ModelAndView("redirect:/clients/api_key_request/history");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -533,7 +527,7 @@ public class MyAppController {
registration.setIpWhitelistFromString(ipWhitelist);
}
return new ModelAndView("redirect:/myapikey/register/step2");
return new ModelAndView("redirect:/clients/register/step2");
} catch (Exception e) {
setupStepModel(model, 1);
@@ -598,7 +592,7 @@ public class MyAppController {
// 1단계가 완료되었는지 검증
if (!registration.isStep1Complete()) {
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
return new ModelAndView("redirect:/myapikey/register/step1");
return new ModelAndView("redirect:/clients/register/step1");
}
// 서비스 카테고리만 가져오기 (API는 AJAX로 로드됨)
@@ -629,7 +623,7 @@ public class MyAppController {
}
// 1단계로 리다이렉트
return new ModelAndView("redirect:/myapikey/register/step1");
return new ModelAndView("redirect:/clients/register/step1");
}
/**
@@ -647,7 +641,7 @@ public class MyAppController {
// 1단계가 완료되었는지 검증
if (!registration.isStep1Complete()) {
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
return new ModelAndView("redirect:/myapikey/register/step1");
return new ModelAndView("redirect:/clients/register/step1");
}
// API 선택은 선택 사항 — 미선택(빈 목록)도 허용한다.
@@ -656,7 +650,7 @@ public class MyAppController {
// 등록이 완료되었는지 최종 검증
if (!registration.isComplete()) {
redirectAttributes.addFlashAttribute("error", "등록 정보가 완전하지 않습니다.");
return new ModelAndView("redirect:/myapikey/register/step1");
return new ModelAndView("redirect:/clients/register/step1");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -673,12 +667,12 @@ public class MyAppController {
// 성공적으로 완료된 후 세션 초기화
sessionStatus.setComplete();
return new ModelAndView("redirect:/myapikey/register/step3");
return new ModelAndView("redirect:/clients/register/step3");
} catch (Exception e) {
// 실패 시 에러 메시지와 함께 step2로 돌아감
redirectAttributes.addFlashAttribute("error", "API Key 등록 중 오류가 발생했습니다. 다시 시도해주세요.");
return new ModelAndView("redirect:/myapikey/register/step2");
return new ModelAndView("redirect:/clients/register/step2");
}
}
@@ -696,7 +690,7 @@ public class MyAppController {
// 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우
if (registrationSuccess == null || !registrationSuccess) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
// 결과 페이지 표시용 속성 설정
@@ -715,7 +709,7 @@ public class MyAppController {
public String cancelRegistration(SessionStatus sessionStatus) {
// 등록과 관련된 세션 데이터 초기화
sessionStatus.setComplete();
return "redirect:/myapikey";
return "redirect:/clients";
}
/**
@@ -759,13 +753,15 @@ public class MyAppController {
@Secured("ROLE_API_KEY_REQUEST")
public ModelAndView modifyStep1(
@RequestParam(value = "clientId", required = false) String clientId,
@RequestParam(value = "goto", required = false) String gotoStep,
@RequestParam(value = "apiApplyToast", required = false) String apiApplyToast,
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
SessionStatus sessionStatus,
Model model,
RedirectAttributes redirectAttributes) {
if (clientId == null) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -773,7 +769,7 @@ public class MyAppController {
// 기존 API Key 정보 조회
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), clientId);
if (apiKey == null) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
// 새로운 수정 세션 시작시에만 초기화
@@ -815,6 +811,16 @@ public class MyAppController {
model.addAttribute("apiKeyModification", modification);
}
// API 신청 절차: 클라이언트 1건 보유 시 API 상세에서 goto=apis 로 진입
// → 세션 초기화 후 API 선택(2단계)로 직행 (기본 정보 미완성이면 step2 가드가 1단계로 되돌림)
if ("apis".equals(gotoStep)) {
String redirectUrl = "redirect:/clients/modify/step2";
if (apiApplyToast != null && apiApplyToast.matches("[a-zA-Z_-]{1,30}")) {
redirectUrl += "?apiApplyToast=" + apiApplyToast;
}
return new ModelAndView(redirectUrl);
}
// Step 모델 설정
setupStepModel(model, 1);
model.addAttribute("userOrg", user.getPortalOrg());
@@ -866,7 +872,7 @@ public class MyAppController {
modification.setIpWhitelistFromString(ipWhitelist);
}
return new ModelAndView("redirect:/myapikey/modify/step2");
return new ModelAndView("redirect:/clients/modify/step2");
} catch (Exception e) {
setupStepModel(model, 1);
@@ -888,7 +894,7 @@ public class MyAppController {
// 1단계가 완료되었는지 검증
if (!modification.isStep1Complete()) {
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
}
// 서비스 카테고리와 API 목록 가져오기
@@ -921,7 +927,7 @@ public class MyAppController {
}
// 1단계로 리다이렉트
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
}
/**
@@ -940,13 +946,13 @@ public class MyAppController {
// 1단계가 완료되었는지 검증
if (!modification.isStep1Complete()) {
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
}
// API 선택 검증
if (selectedApis == null || selectedApis.isEmpty()) {
redirectAttributes.addFlashAttribute("error", "최소 1개 이상의 API를 선택해주세요.");
return new ModelAndView("redirect:/myapikey/modify/step2");
return new ModelAndView("redirect:/clients/modify/step2");
}
// 선택된 API를 세션에 저장
@@ -955,7 +961,7 @@ public class MyAppController {
// 등록이 완료되었는지 최종 검증
if (!modification.isComplete()) {
redirectAttributes.addFlashAttribute("error", "수정 정보가 완전하지 않습니다.");
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
}
// 반영 직전 2FA: 통과권이 없으면 커밋하지 않고 step2 로 되돌린다(프론트가 먼저 2FA 팝업을 띄운다).
@@ -963,7 +969,7 @@ public class MyAppController {
if (isAppModifyTwofaRequired()
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.APP_MODIFY_COMMIT)) {
redirectAttributes.addFlashAttribute("error", "추가 인증(2FA) 후 다시 시도해 주세요.");
return new ModelAndView("redirect:/myapikey/modify/step2");
return new ModelAndView("redirect:/clients/modify/step2");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -980,12 +986,12 @@ public class MyAppController {
// 성공적으로 완료된 후 세션 초기화
sessionStatus.setComplete();
return new ModelAndView("redirect:/myapikey/modify/step3");
return new ModelAndView("redirect:/clients/modify/step3");
} catch (Exception e) {
// 실패 시 에러 메시지와 함께 step2로 돌아감
redirectAttributes.addFlashAttribute("error", "API Key 수정 요청 중 오류가 발생했습니다. 다시 시도해주세요.");
return new ModelAndView("redirect:/myapikey/modify/step2");
return new ModelAndView("redirect:/clients/modify/step2");
}
}
@@ -1002,7 +1008,7 @@ public class MyAppController {
// 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우
if (modificationComplete == null || !modificationComplete) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
// 결과 페이지 표시용 속성 설정
@@ -1031,9 +1037,9 @@ public class MyAppController {
// clientId가 있으면 상세 페이지로, 없으면 목록으로
if (clientId != null && !clientId.isEmpty()) {
return "redirect:/myapikey/credential_detail?id=" + clientId;
return "redirect:/clients/credential_detail?id=" + clientId;
} else {
return "redirect:/myapikey";
return "redirect:/clients";
}
}
@@ -41,11 +41,11 @@ public final class StepUpProtectedPaths {
}
/** Secret 키 조회 (AJAX POST) */
public static final String REVEAL_SECRET = "/myapikey/credential/reveal-secret";
public static final String REVEAL_SECRET = "/clients/credential/reveal-secret";
/** 앱 해지 신청 (AJAX POST) */
public static final String APP_KEY_DELETE = "/myapikey/api_key_delete";
public static final String APP_KEY_DELETE = "/clients/api_key_delete";
/** 앱 정보 수정 최종 반영(commit, POST /modify/step2) — 반영 직전 2FA. 진입/중간 단계는 가드하지 않음 */
public static final String APP_MODIFY_COMMIT = "/myapikey/modify/step2";
public static final String APP_MODIFY_COMMIT = "/clients/modify/step2";
/** 개인정보 변경 페이지 진입 (GET, 정확 일치) — PASSWORD 레벨 */
public static final String MYPAGE = "/mypage";
/** 비밀번호 변경 반영(commit, POST) — 반영 직전 2FA. 진입(GET)은 가드하지 않음 */
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.apps.auth.twofactor;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@@ -20,6 +21,7 @@ public class TwoFactorProperties {
public static final String GROUP = "Portal";
public static final String KEY_LOGIN_ENABLED = "two-factor.login.enabled";
public static final String KEY_LOGIN_TARGET_ROLES = "two-factor.login.target-roles";
public static final String KEY_TTL_SECONDS = "two-factor.ttl.seconds";
public static final String KEY_ATTEMPT_LIMIT = "two-factor.attempt.limit";
public static final String KEY_TEST_NOTICE_ENABLED = "two-factor.test-notice.enabled";
@@ -32,6 +34,33 @@ public class TwoFactorProperties {
return parseBool(resolve(KEY_LOGIN_ENABLED, "false", "로그인 2차 인증 활성화 여부 (true/false)"));
}
/**
* 로그인 2FA 적용 대상 역할인지 여부.
* 프로퍼티 값: 쉼표 구분 RoleCode 목록(예: {@code ROLE_CORP_MANAGER,ROLE_CORP_USER})
* 또는 {@code ALL}(전체 대상). 기본값은 법인관리자만.
* 미기재 역할은 로그인 2FA 를 건너뛴다(전체 스위치 {@link #isLoginEnabled()}와 AND 동작).
*/
public boolean isLoginTargetRole(RoleCode roleCode) {
if (roleCode == null) {
roleCode = RoleCode.ROLE_USER;
}
String value = resolve(KEY_LOGIN_TARGET_ROLES, RoleCode.ROLE_CORP_MANAGER.name(),
"로그인 2차 인증 대상 역할 (쉼표구분: ROLE_USER,ROLE_CORP_USER,ROLE_CORP_MANAGER / 전체: ALL)");
if (value == null || value.trim().isEmpty()) {
return false;
}
String trimmed = value.trim();
if ("ALL".equalsIgnoreCase(trimmed)) {
return true;
}
for (String token : trimmed.split(",")) {
if (roleCode.name().equalsIgnoreCase(token.trim())) {
return true;
}
}
return false;
}
/** step-up(민감기능) 2FA 전체 활성화 여부(마스터 스위치) */
public boolean isStepUpEnabled() {
return parseBool(resolve(KEY_STEPUP_ENABLED, "false", "민감기능 추가 인증(step-up) 전체 활성화 여부 (true/false)"));
@@ -118,6 +118,14 @@ public class LoginFinalizer {
}
}
// 법인 사용자(관리자/개발자) 로그인 시, 홈 최초 진입에서 클라이언트 신규 신청 유도 팝업을 1회 노출하도록 마킹한다.
// 실제 클라이언트(승인+요청중) 보유 여부 판정과 1회 소비는 IndexController 가 담당한다.
PortalUserEnums.RoleCode roleCode = user.getRoleCode();
if (roleCode == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER
|| roleCode == PortalUserEnums.RoleCode.ROLE_CORP_USER) {
session.setAttribute("checkClientRegister", true);
}
// 중복 로그인 방지: 기존 세션 강제 로그아웃 + 현재 세션 등록
String clientIp = HttpRequestUtil.getClientIpAddress(request);
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
@@ -1,10 +1,14 @@
package com.eactive.apim.portal.apps.main.controller;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
import com.eactive.apim.portal.apps.main.dto.IndexStatisticsDTO;
import com.eactive.apim.portal.apps.main.service.IndexStatisticsService;
import com.eactive.apim.portal.apps.main.service.MainApiFacade;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import java.util.List;
import javax.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -17,6 +21,7 @@ public class IndexController {
private final MainApiFacade mainApiFacade;
private final IndexStatisticsService indexStatisticsService;
private final AppServiceFacade appServiceFacade;
/**
* 메인 페이지 API는 Portal Property 에 main.service.list 에 등록된 그룹을 기준으로 API를 조회함.
@@ -29,7 +34,7 @@ public class IndexController {
* @return
*/
@GetMapping("/")
public String index(Model model) {
public String index(Model model, HttpSession session) {
List<ApiServiceDTO> services = mainApiFacade.getOpenApiServices();
List<String> hashTags = mainApiFacade.getHashTags();
@@ -39,9 +44,37 @@ public class IndexController {
addAttributesToModel(model, services, hashTags, statistics);
resolveClientRegisterNudge(model, session);
return "apps/main/index";
}
/**
* 법인 사용자(관리자/개발자) 로그인 직후, 클라이언트(승인+요청중)가 하나도 없으면
* 클라이언트 신규 신청 유도 팝업 노출 플래그를 세팅한다.
*
* <p>노출 시점은 {@code LoginFinalizer} 가 로그인 시 세팅한 {@code checkClientRegister}
* 세션 마커로 통제한다. 마커는 홈 최초 진입에서 1회 소비하여, 이후 홈 재방문 시
* 반복 노출되지 않게 한다. (역할 게이팅은 마커 세팅 측에서 이미 수행됨)</p>
*/
private void resolveClientRegisterNudge(Model model, HttpSession session) {
if (session.getAttribute("checkClientRegister") == null) {
return;
}
session.removeAttribute("checkClientRegister"); // 1회 소비
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user == null || user.getPortalOrg() == null) {
return;
}
boolean hasClient = !appServiceFacade.getApikeyList(user.getPortalOrg()).isEmpty()
|| !appServiceFacade.getPendingApiKeyList(user.getPortalOrg()).isEmpty();
if (!hasClient) {
model.addAttribute("needClientRegister", true);
}
}
private void addAttributesToModel(Model model, List<ApiServiceDTO> apiServices, List<String> hashTags, IndexStatisticsDTO statistics) {
model.addAttribute("services", apiServices);
@@ -172,6 +172,44 @@ public class StringMaskingUtil {
return value;
}
// 폼(application/x-www-form-urlencoded) 본문에서 값을 리댁트할 파라미터 키(소문자 완전일치)
private static final java.util.Set<String> SENSITIVE_FORM_PARAMS = new java.util.HashSet<>(Arrays.asList(
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
"refresh_token", "access_token", "id_token", "code", "assertion"));
/**
* {@code application/x-www-form-urlencoded} 본문 로깅 시 민감 파라미터(client_secret/password 등)의
* 값을 {@link #maskToken(String)} 규칙(앞4·뒤4)으로 마스킹한다. client_id·grant_type·scope 등은
* "client not found" 류 원인 파악을 위해 원본 그대로 남긴다. 폼 형식이 아니면 원본을 반환한다(멱등).
* <pre>grant_type=client_credentials&client_id=ABC&client_secret=s3cr3tValue → …&client_secret=s3cr***alue</pre>
*/
public static String maskFormBody(String body) {
if (!isValidString(body) || body.indexOf('=') < 0) {
return body;
}
String[] pairs = body.split("&");
StringBuilder sb = new StringBuilder(body.length());
for (int i = 0; i < pairs.length; i++) {
if (i > 0) {
sb.append('&');
}
String pair = pairs[i];
int eq = pair.indexOf('=');
if (eq < 0) {
sb.append(pair);
continue;
}
String key = pair.substring(0, eq);
String value = pair.substring(eq + 1);
if (SENSITIVE_FORM_PARAMS.contains(key.toLowerCase())) {
sb.append(key).append('=').append(maskToken(value));
} else {
sb.append(pair);
}
}
return sb.toString();
}
// 기존 메서드 오버로딩 (하위 호환성)
public static String maskName(String name) {
return maskName(name, null, null);
@@ -60,7 +60,9 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
boolean dormant = PortalUserEnums.UserStatus.DORMANT.equals(user.getUserStatus());
// 로그인 2FA: ID/PW 는 맞았으므로 실패카운트만 리셋하고, 최종 확정은 2FA 성공까지 보류한다.
if (twoFactorProperties.isLoginEnabled() && !dormant) {
// 대상 역할(two-factor.login.target-roles, 기본 법인관리자만)에 한해 적용한다.
if (twoFactorProperties.isLoginEnabled() && !dormant
&& twoFactorProperties.isLoginTargetRole(user.getRoleCode())) {
user.setLoginFailureCount(0);
portalUserRepository.save(user);
+15 -15
View File
@@ -367,37 +367,37 @@ page:
path: "/users/detail"
apikey:
name: "API 신청 관리"
path: "/myapikey"
path: "/clients"
app_request_detail:
name: "인증키 신청 상세"
path: "/myapikey/app_request_detail"
path: "/clients/app_request_detail"
credential_detail:
name: "인증키 정보"
path: "/myapikey/credential_detail"
path: "/clients/credential_detail"
password_verify:
name: "비밀번호 변경"
path: "/password/verify"
password_change:
name: "비밀번호 변경"
path: "/password/change"
myapikey_register_step1:
clients_register_step1:
name: "앱 생성 (기본 정보)"
path: "/myapikey/register/step1"
myapikey_register_step2:
path: "/clients/register/step1"
clients_register_step2:
name: "앱 생성 (API 선택)"
path: "/myapikey/register/step2"
myapikey_register_step3:
path: "/clients/register/step2"
clients_register_step3:
name: "앱 생성 요청 완료"
path: "/myapikey/register/step3"
myapikey_modify_step1:
path: "/clients/register/step3"
clients_modify_step1:
name: "앱 수정 (기본 정보)"
path: "/myapikey/modify/step1"
myapikey_modify_step2:
path: "/clients/modify/step1"
clients_modify_step2:
name: "앱 수정 (API 선택)"
path: "/myapikey/modify/step2"
myapikey_modify_step3:
path: "/clients/modify/step2"
clients_modify_step3:
name: "앱 수정 요청 완료"
path: "/myapikey/modify/step3"
path: "/clients/modify/step3"
api_statistics:
name: "이용 통계"
path: "/statistics/api"
+3 -2
View File
@@ -7764,9 +7764,8 @@ button.djb-comment-submit:disabled {
.tfa-code-row .tfa-code-input-wrap {
flex: 1;
display: flex;
align-items: center;
align-items: stretch;
height: 56px;
padding: 0 14px;
border: 1px solid #BDC7CF;
border-radius: 4px;
background: #fff;
@@ -7778,6 +7777,8 @@ button.djb-comment-submit:disabled {
.tfa-code-row .tfa-code-input {
flex: 1;
min-width: 0;
height: 100%;
padding: 0 14px;
border: 0;
outline: none;
background: transparent;
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -449,7 +449,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Form submit
// - Webhook/앱 변경은 API 가 필수(form data-api-required=true)이므로 미선택 시 차단한다.
// - 앱 신청(myapikey register)은 선택 사항이라, 미선택 시 확인 팝업으로 한 번 더 묻고 진행한다.
// - 앱 신청(clients register)은 선택 사항이라, 미선택 시 확인 팝업으로 한 번 더 묻고 진행한다.
form.addEventListener('submit', function(e) {
if (selectedApis.size !== 0) {
syncHiddenSelected();
+3 -1
View File
@@ -5,6 +5,7 @@
* 추가로, 페이지 진입 시 URL 쿼리 `apiApplyToast` 값에 따라 안내 토스트를 자동 노출한다.
* - new : 신규 클라이언트 생성 페이지 진입 안내
* - existing : 클라이언트 목록(선택) 진입 안내
* - modify : 클라이언트 1건 보유 → 수정 플로우 API 선택 직행 안내
* 토스트 노출 후 쿼리를 정리(replaceState)해 새로고침 시 재노출을 막는다.
*/
(function (global) {
@@ -54,7 +55,8 @@
// ── URL 쿼리 기반 자동 안내 토스트 ──
var API_APPLY_MESSAGES = {
"new": "API 를 사용할 클라이언트 생성을 먼저 진행해주세요.",
"existing": "API 를 추가 사용할 클라이언트를 선택해주세요"
"existing": "API 를 추가 사용할 클라이언트를 선택해주세요",
"modify": "보유 클라이언트에 추가 사용할 API 를 선택해주세요"
};
function handleApiApplyToast() {
@@ -31,6 +31,7 @@ var LoginSuccessHandler = (function() {
pendingInvitation: false,
pendingInvitationToken: '',
pendingInvitationOrgName: '',
needClientRegister: false,
sessionSuccessMsg: '',
redirectUrl: ''
};
@@ -117,6 +118,19 @@ var LoginSuccessHandler = (function() {
}
}
);
return; // 다른 체크 중단
}
// 6. 클라이언트(앱/API키) 미보유 법인 사용자 → 신규 신청 유도 (로그인 직후 1회)
if (config.needClientRegister) {
customPopups.showConfirm(
'API 사용을 위해서는 클라이언트 신규 신청이 필요합니다.<br>지금 신청하시겠습니까?',
function(selection) {
if (selection) {
window.location.href = '/clients/register/step1';
}
}
);
}
}
@@ -133,6 +133,7 @@ const customPopups = {
// 기본값 설정
const title = options.title || '비밀번호 입력';
const message = options.message || '계속하려면 비밀번호를 입력해주세요.';
const placeholder = options.placeholder || '비밀번호를 입력하세요';
const onConfirm = options.onConfirm;
const onCancel = options.onCancel;
@@ -140,8 +141,8 @@ const customPopups = {
$('#passwordPopupTitle').text(title);
$('#passwordPopupMessage').html(customPopups._sanitizeHtml(message));
// 입력 필드 및 에러 초기화
$('#passwordPopupInput').val('').removeClass('error');
// 입력 필드 및 에러 초기화 (placeholder 는 호출부 커스텀 허용)
$('#passwordPopupInput').val('').removeClass('error').attr('placeholder', placeholder);
$('#passwordPopupError').removeClass('show').text('');
// 팝업 표시 (modal 구조 사용)
@@ -3,7 +3,7 @@
*
* TwoFactorAuth.open({
* mode: 'login' | 'stepup', // 참고용(서버가 세션으로 판별). 로깅/분기용
* purpose: '/myapikey/...', // step-up 대상 보호 경로(로그인은 생략)
* purpose: '/clients/...', // step-up 대상 보호 경로(로그인은 생략)
* onSuccess: function(res){}, // 검증 성공. login 이면 res.redirect 사용
* onCancel: function(reason){}// 닫기/타임아웃/실패로 종료
* });
@@ -53,7 +53,7 @@
'op.tryItOut': { en: 'Try it out', ko: '실행해보기' },
'op.cancel': { en: 'Cancel', ko: '취소' },
'op.execute': { en: 'Execute', ko: '실행' },
'op.clear': { en: 'Clear', ko: '응답 지우기' },
'op.clear': { en: 'Clear', ko: 'Reset (응답지우기)' },
'op.reset': { en: 'Reset', ko: '초기화' },
'op.parameters': { en: 'Parameters', ko: '파라미터' },
'op.parameter': { en: 'Parameter', ko: '파라미터' },
@@ -203,9 +203,8 @@ $tfa-danger: #F4253C;
.tfa-code-input-wrap {
flex: 1;
display: flex;
align-items: center;
align-items: stretch;
height: 56px;
padding: 0 14px;
border: 1px solid $tfa-border-2;
border-radius: 4px;
background: #fff;
@@ -217,6 +216,8 @@ $tfa-danger: #F4253C;
.tfa-code-input {
flex: 1;
min-width: 0;
height: 100%;
padding: 0 14px;
border: 0;
outline: none;
background: transparent;
@@ -281,6 +281,8 @@
// 활성 탭은 서버가 결정하고, 테스트베드 탭이 활성이면서 인증된 경우에만
// Swagger UI를 초기화한다(실제 초기화는 하단에서 실행 — const 선언 이후).
const currentApiId = /*[[${apiSpecInfo.apiId}]]*/ 'default';
// 현재 API 의 응답유형(sample/mock/gw) — mock 이면 샘플 Secret + mock 토큰으로 인증 진행
const CURRENT_RESPONSE_TYPE = /*[[${apiSpecInfo.responseType}]]*/ 'sample';
const activeTab = /*[[${activeTab}]]*/ 'api-info';
const isAuthenticated = /*[[${authenticated}]]*/ false;
const testbedActive = (activeTab === 'testbed' && isAuthenticated);
@@ -421,7 +423,7 @@
// ===== API 사용 신청: 회원 구분별 분기 (API 신청 절차) =====
// 비회원/개인회원 → 법인회원 안내 팝업 → 회원가입 안내 이동
// 법인이용자 → 클라이언트 현황: 신규(0건)→신규 클라이언트 / 기존(앱 있음)→클라이언트 관리
// 법인이용자 → 클라이언트 현황: 신규(0건)→신규 클라이언트 / 기존(1건)→클라이언트 수정의 API 선택 직행 / 기존(1건 초과)→클라이언트 관리
function requestApiUse() {
fetch(/*[[@{/djb/testbed/auth/context}]]*/ '/djb/testbed/auth/context')
.then(function (r) { return r.json(); })
@@ -438,16 +440,24 @@
} else { go(); }
return;
}
// 법인이용자: 보유 클라이언트 유무로 신규/기존 분기 (이동 페이지에서 Toast 안내)
const hasClients = ctx && ctx.credentials && ctx.credentials.length > 0;
if (hasClients) {
// 기존 → 클라이언트 목록(선택) + Toast
window.location.href = /*[[@{/myapikey(apiApplyToast='existing')}]]*/ '/myapikey?apiApplyToast=existing';
// 법인이용자: 보유 클라이언트 로 신규/기존 분기 (이동 페이지에서 Toast 안내)
const creds = (ctx && ctx.credentials) || [];
if (creds.length === 1) {
// 기존 1건 → 해당 클라이언트 수정 플로우의 API 선택(2단계) 직행 + Toast
const modifyBase = /*[[@{/clients/modify/step1}]]*/ '/clients/modify/step1';
window.location.href = modifyBase
+ '?clientId=' + encodeURIComponent(creds[0].clientId)
+ '&goto=apis&apiApplyToast=modify';
return;
}
if (creds.length > 1) {
// 기존 여러 건 → 클라이언트 목록(선택) + Toast
window.location.href = /*[[@{/clients(apiApplyToast='existing')}]]*/ '/clients?apiApplyToast=existing';
return;
}
// 신규(클라이언트 0) → 생성 필요 안내 확인 후 이동
const goNew = function () {
window.location.href = /*[[@{/myapikey/register/step1(clear=true,apiApplyToast='new')}]]*/ '/myapikey/register/step1?clear=true&apiApplyToast=new';
window.location.href = /*[[@{/clients/register/step1(clear=true,apiApplyToast='new')}]]*/ '/clients/register/step1?clear=true&apiApplyToast=new';
};
if (typeof customPopups !== 'undefined' && customPopups.showConfirm) {
customPopups.showConfirm(
@@ -458,7 +468,7 @@
})
.catch(function (e) {
console.error('API 사용 신청 컨텍스트 조회 실패', e);
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
window.location.href = /*[[@{/clients}]]*/ '/clients';
});
}
Array.prototype.forEach.call(document.querySelectorAll('.api-apply-btn'), function (btn) {
@@ -533,33 +543,119 @@
.catch(function (e) { console.error('테스트베드 컨텍스트 로드 실패', e); });
}
function fetchOAuthToken(clientId, clientSecret, gw) {
// 인증 실패/취소 시 앱 선택을 미선택 상태로 되돌린다 (mock 포함 — 선택 유지 시 인증된 것처럼 보이는 오해 방지)
function resetAppSelection() {
if (appsSelect) appsSelect.value = '';
}
// 테스트베드 오류 안내 — modal dialog (custom-popups)
function showTestbedError(msg) {
if (typeof customPopups !== 'undefined' && customPopups.showAlert) {
customPopups.showAlert(msg);
} else {
alert(String(msg).replace(/<br\s*\/?>/gi, '\n'));
}
}
function fetchOAuthToken(clientId, clientSecret, tokenUrl) {
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 url = direct ? 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['original-url'] = 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; });
}).then(function (r) {
// 실패 시에도 프록시(ApiTesterFilter)가 {error, detail} JSON 을 반환 — 메시지로 살려 UI 에 표시
return r.json().catch(function () { return null; }).then(function (data) {
if (!r.ok || !data || !data.access_token) {
var msg = (data && (data.error || data.error_description))
|| ('토큰 발급 요청 실패 (HTTP ' + r.status + ')');
var detail = (data && data.detail) ? '<br><span style="color:#888;font-size:13px;">' + data.detail + '</span>' : '';
throw new Error('인증 토큰 발급에 실패했습니다.<br>' + msg + detail);
}
return data.access_token;
});
});
}
// 현재 API 가 mock 응답유형인지 (전역 게이트웨이 설정과 무관하게 API 별 판단)
function isMockApi() {
return (CURRENT_RESPONSE_TYPE || '').toLowerCase() === 'mock';
}
function authorizeApp(secret) {
const gw = window.__djbGateway || {};
if (!window.ui) return;
// 응답유형 mock: 실 Secret 검증 없이 샘플 SecretKey + mock 토큰으로 인증 진행 — toast 안내
if (isMockApi()) {
if (typeof djbToast === 'function') {
djbToast('Mock 서버로 호출되므로 별도 Secret Key 로 인증 절차를 진행합니다.', { type: 'info', duration: 5000 });
}
proceedAuthorize(secret, secret.clientSecret || 'mock-secret', gw);
return;
}
// gw 응답유형 + Client Secret 이미 1회 노출·삭제 → 사용자가 보관 중인 Secret 직접 입력
if (!secret.clientSecret) {
promptClientSecret(secret, gw);
return;
}
proceedAuthorize(secret, secret.clientSecret, gw);
}
// Client Secret 수동 입력 팝업 (포탈에는 이미 삭제된 경우)
function promptClientSecret(secret, gw) {
if (typeof customPopups === 'undefined' || !customPopups.showPasswordInput) {
resetAppSelection();
showTestbedError('선택한 앱의 Client Secret 이 포탈에 존재하지 않습니다.<br>인증키를 새로 신청해 주세요.');
return;
}
customPopups.showPasswordInput({
title: 'Client Secret 입력',
message: 'Client Secret 은 1회 노출 후 삭제되어 포탈에 없습니다.<br>'
+ '보관 중인 값을 입력해 주세요.<br><br>'
+ '입력한 값은 저장되지 않고 인증에만 사용됩니다.',
placeholder: 'Client Secret 을 입력하세요',
onConfirm: function (value) {
customPopups.hidePasswordInput();
proceedAuthorize(secret, value, gw);
},
onCancel: function () {
// 취소 — 인증 미완료이므로 앱 선택도 되돌린다
resetAppSelection();
}
});
}
// authorize 반영 직후, 이미 열려 있는 "요청 스니펫" 상시 패널을 재렌더해 인증 헤더를 즉시 반영.
// (패널은 탭 클릭/Execute/입력 변경에만 자체 갱신하므로 앱 선택 시점엔 명시 호출이 필요)
function refreshSnippetPanel() {
if (window.DjbSwaggerSnippetPanel && typeof DjbSwaggerSnippetPanel.render === 'function') {
// authorize 상태가 Swagger store 에 반영된 뒤 읽도록 한 틱 늦춘다
setTimeout(function () { DjbSwaggerSnippetPanel.render(); }, 50);
}
}
// 확보된 Client Secret 으로 인증 주입 (OAUTH: 토큰 발급 / API_KEY: 헤더 주입)
// 토큰 URL: mock API → 포탈 mock 토큰 경로(ApiTesterFilter 가 즉시 발급), gw API → 실 GW 토큰 엔드포인트
function proceedAuthorize(secret, clientSecret, gw) {
var tokenUrl = isMockApi()
? window.location.origin + '/api/v1/oauth/token'
: gw.tokenUrl;
if (secret.authType === 'OAUTH') {
fetchOAuthToken(secret.clientId, secret.clientSecret, gw).then(function (token) {
if (!token) return;
fetchOAuthToken(secret.clientId, clientSecret, tokenUrl).then(function (token) {
window.ui.authActions.authorize({
djbOAuth: {
name: 'djbOAuth',
@@ -567,15 +663,21 @@
value: 'Bearer ' + token
}
});
refreshSnippetPanel();
}).catch(function (e) {
console.error('토큰 발급 실패', e);
resetAppSelection();
showTestbedError(e && e.message ? e.message : '인증 토큰 발급 중 오류가 발생했습니다.');
});
} else if (secret.authType === 'API_KEY') {
window.ui.authActions.authorize({
djbApiKey: {
name: 'djbApiKey',
schema: { type: 'apiKey', in: 'header', name: gw.apiKeyHeader },
value: secret.clientSecret
value: clientSecret
}
});
refreshSnippetPanel();
}
}
@@ -584,9 +686,18 @@
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 (r) {
if (!r.ok) {
throw new Error('앱 인증정보 조회에 실패했습니다. (HTTP ' + r.status + ')');
}
return r.json();
})
.then(function (secret) { if (secret) authorizeApp(secret); })
.catch(function (e) { console.error('앱 인증정보 주입 실패', e); });
.catch(function (e) {
console.error('앱 인증정보 주입 실패', e);
resetAppSelection();
showTestbedError(e && e.message ? e.message : '앱 인증정보 주입 중 오류가 발생했습니다.');
});
}
// 테스트베드 탭 활성 + 인증 상태일 때만 Swagger UI/앱 인증 컨텍스트 초기화
@@ -328,7 +328,7 @@
</p>
<div class="action-buttons">
<a th:href="@{/service/intro}" class="action-btn btn-secondary">처음 만나는 DJBank API</a>
<a href="#" class="action-btn btn-primary">피드백 / 개선요청 <i class="bi bi-patch-question"></i></a>
<a th:href="@{/partnership}" class="action-btn btn-primary">피드백 / 개선요청 <i class="bi bi-patch-question"></i></a>
</div>
</div>
<div class="info-image-box">
@@ -607,6 +607,7 @@
pendingInvitation: [[${ session.pendingInvitation }]],
pendingInvitationToken: [[${ session.pendingInvitationToken }]],
pendingInvitationOrgName: [[${ session.pendingInvitationOrgName }]],
needClientRegister: [[${ needClientRegister }]],
sessionSuccessMsg: [[${ session.success }]],
redirectUrl: [[${ session.redirectUrl }]]
});
@@ -35,7 +35,7 @@
<!-- App Requests (Pending) -->
<th:block th:if="${appRequests != null and !appRequests.isEmpty()}">
<a class="app-card-figma" th:each="request : ${appRequests}"
th:href="@{/myapikey/app_request_detail(id=${request.id})}">
th:href="@{/clients/app_request_detail(id=${request.id})}">
<!-- App Icon -->
<div class="app-card-icon-box">
@@ -85,7 +85,7 @@
<!-- API Keys (Approved/Inactive) -->
<th:block th:if="${apiKeys != null and !apiKeys.isEmpty()}">
<a class="app-card-figma" th:each="apikey : ${apiKeys}"
th:href="@{/myapikey/credential_detail(id=${apikey.clientid})}">
th:href="@{/clients/credential_detail(id=${apikey.clientid})}">
<!-- App Icon -->
<div class="app-card-icon-box">
@@ -156,7 +156,7 @@
keysToRemove.forEach(key => sessionStorage.removeItem(key));
// Redirect to the API key registration wizard with clear parameter
window.location.href = '/myapikey/register/step1?clear=true';
window.location.href = '/clients/register/step1?clear=true';
}
if (requestApiKeyBtn) {
@@ -22,12 +22,12 @@
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title">클라이언트 정보 - 수정</h2>
<h2 class="s1-title">클라이언트 정보 - 변경</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1 Active: 정보수정 (form/input icon) -->
<!-- Step 1 Active: 클라이언트 정보수정 (form/input icon) -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
@@ -39,7 +39,7 @@
</svg>
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name"> 정보수정</span>
<span class="s1-step-name">클라이언트 정보수정</span>
</div>
<div class="s1-step-line"></div>
@@ -71,7 +71,7 @@
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">클라이언트 신청 완료</span>
<span class="s1-step-name">변경 신청 완료</span>
</div>
</div>
@@ -84,7 +84,7 @@
<!-- Form Card -->
<div class="s1-form-card">
<form id="modifyStep1Form" method="post" th:action="@{/myapikey/modify/step1}"
<form id="modifyStep1Form" method="post" th:action="@{/clients/modify/step1}"
th:object="${apiKeyModification}" enctype="multipart/form-data">
<!-- Hidden clientId -->
@@ -173,7 +173,7 @@
<!-- 다음 버튼 -->
<div class="s1-actions" style="justify-content: flex-end; gap: 10px;">
<a th:href="@{/myapikey/modify/cancel(clientId=${apiKeyModification.clientId})}" class="s1-btn-next" style="background: #bdc7cf; flex: none; width: 120px; text-decoration: none; display: flex; justify-content: center; align-items: center;">취소</a>
<a th:href="@{/clients/modify/cancel(clientId=${apiKeyModification.clientId})}" class="s1-btn-next" style="background: #bdc7cf; flex: none; width: 120px; text-decoration: none; display: flex; justify-content: center; align-items: center;">취소</a>
<button type="submit" form="modifyStep1Form" class="s1-btn-next" style="flex: none; width: 120px;">다음</button>
</div>
@@ -349,8 +349,12 @@
if (!ipInput.value.trim()) return; // 빈 값은 무시
addIpAddress(false); // blur 경로: 재포커스 안 함
});
ipInput.addEventListener('keypress', function (e) {
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); }
// Enter/Tab 모두 입력값 등록. Tab 은 다음 tab-stop(추가 버튼)으로 포커스만
// 이동(onclick 미발동)하므로 keydown 에서 직접 추가한다. blur 보다 먼저 실행되어
// 중복 없이 처리된다.
ipInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); return; }
if (e.key === 'Tab' && ipInput.value.trim()) { addIpAddress(false); }
});
// Form submit validation
@@ -23,12 +23,12 @@
<div class="step2-wrap">
<!-- Title -->
<h2 class="s2-title">클라이언트 정보 - 수정</h2>
<h2 class="s2-title">클라이언트 정보 - 변경</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1: 정보수정 (completed) -->
<!-- Step 1: 클라이언트 정보수정 (completed) -->
<div class="s1-step">
<div class="s1-step-circle">
<!-- 입력폼 아이콘 -->
@@ -44,7 +44,7 @@
</svg>
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name"> 정보수정</span>
<span class="s1-step-name">클라이언트 정보수정</span>
</div>
<div class="s1-step-line"></div>
@@ -86,13 +86,13 @@
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">클라이언트 신청 완료</span>
<span class="s1-step-name">변경 신청 완료</span>
</div>
</div>
</div>
<!-- API 선택 공용 모듈 -->
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyModification.selectedApis}, '/myapikey/modify/step2', '/myapikey/modify/step2/save')}"/>
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyModification.selectedApis}, '/clients/modify/step2', '/clients/modify/step2/save')}"/>
<!-- clientId 는 모듈 폼에 form 속성으로 주입 -->
<input type="hidden" name="clientId" th:value="${apiKeyModification.clientId}" form="apiSelectorForm"/>
@@ -135,7 +135,7 @@
if (typeof TwoFactorAuth === 'undefined') { form.submit(); return; }
TwoFactorAuth.open({
mode: 'stepup',
purpose: '/myapikey/modify/step2',
purpose: '/clients/modify/step2',
// form.submit() 은 submit 이벤트를 재발생시키지 않으므로 그대로 서버로 전송된다.
onSuccess: function () { form.submit(); },
onCancel: function () { /* 사용자 취소 — step2 유지 */ }
@@ -24,12 +24,12 @@
<div class="step3-wrap">
<!-- Title -->
<h2 class="s3-title">클라이언트 정보 - 수정</h2>
<h2 class="s3-title">클라이언트 정보 - 변경</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1: 정보수정 (form/input icon) -->
<!-- Step 1: 클라이언트 정보수정 (form/input icon) -->
<div class="s1-step">
<div class="s1-step-circle">
<!-- 입력폼 아이콘 -->
@@ -45,7 +45,7 @@
</svg>
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name"> 정보수정</span>
<span class="s1-step-name">클라이언트 정보수정</span>
</div>
<div class="s1-step-line"></div>
@@ -87,7 +87,7 @@
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">클라이언트 신청 완료</span>
<span class="s1-step-name">변경 신청 완료</span>
</div>
</div>
</div>
@@ -161,7 +161,7 @@
<!-- Bottom Actions -->
<div class="s3-actions">
<a href="/myapikey" class="s3-btn-complete">
<a href="/clients" class="s3-btn-complete">
완료
</a>
</div>
@@ -179,10 +179,10 @@
</p>
</div>
<div class="s3-actions">
<a href="/myapikey" class="s3-btn-retry">
<a href="/clients" class="s3-btn-retry">
다시 시도
</a>
<a href="/myapikey" class="s3-btn-list">
<a href="/clients" class="s3-btn-list">
목록으로
</a>
</div>
@@ -97,7 +97,7 @@
<!-- Form Card -->
<div class="s1-form-card">
<form id="registerStep1Form" method="post" th:action="@{/myapikey/register/step1}"
<form id="registerStep1Form" method="post" th:action="@{/clients/register/step1}"
th:object="${apiKeyRegistration}" enctype="multipart/form-data">
<!-- 앱 이름 -->
@@ -356,9 +356,12 @@
});
}
// IP input Enter key
ipInput.addEventListener('keypress', function (e) {
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); }
// IP input Enter/Tab key — 추가 버튼을 지나쳐도 입력값을 등록한다.
// Tab 은 다음 tab-stop(추가 버튼)으로 포커스만 이동(onclick 미발동)하므로
// keydown 에서 직접 추가한다. blur 보다 먼저 실행되어 중복 없이 처리된다.
ipInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); return; }
if (e.key === 'Tab' && ipInput.value.trim()) { addIpAddress(false); }
});
// 포커스 아웃 시 자동 추가/검증 (사용자가 "추가" 버튼을 지나치는 경우 대응).
@@ -93,7 +93,7 @@
<!-- API 선택 공용 모듈 -->
<th:block
th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyRegistration.selectedApis}, '/myapikey/register/step2', '/myapikey/register/step2/save')}" />
th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyRegistration.selectedApis}, '/clients/register/step2', '/clients/register/step2/save')}" />
<!-- Bottom Navigation Actions -->
<div class="s2-actions">
@@ -161,7 +161,7 @@
<!-- Bottom Actions -->
<div class="s3-actions">
<a href="/myapikey" class="s3-btn-complete">
<a href="/clients" class="s3-btn-complete">
완료
</a>
</div>
@@ -179,10 +179,10 @@
</p>
</div>
<div class="s3-actions">
<a href="/myapikey/register/step1" class="s3-btn-retry">
<a href="/clients/register/step1" class="s3-btn-retry">
다시 시도
</a>
<a href="/myapikey" class="s3-btn-list">
<a href="/clients" class="s3-btn-list">
목록으로
</a>
</div>
@@ -158,13 +158,13 @@
</div>
<div class="btn_wrap bt_location" th:if="${appRequest.approval.approvalStatus.toString() == 'REQUESTED'}">
<form th:action="@{/myapikey/api_key_request/cancel}" method="post">
<form th:action="@{/clients/api_key_request/cancel}" method="post">
<input type="hidden" name="id" th:value="${appRequest.id}">
<button type="submit" class="btn_del">취소</button>
</form>
</div>
<div class="btn_inventory btn_mtop">
<a th:href="@{/myapikey/api_key_request/history}" class="common_btn_type_1">목록</a>
<a th:href="@{/clients/api_key_request/history}" class="common_btn_type_1">목록</a>
</div>
</div>
</div>
@@ -15,10 +15,10 @@
<div class="tabs">
<ul class="tab_nav tab_bt">
<li class="active">
<a th:href="@{/myapikey/api_key_request/history}">개발</a>
<a th:href="@{/clients/api_key_request/history}">개발</a>
</li>
<li>
<a th:href="@{/myapikey/api_key_request/prod_history}">운영</a>
<a th:href="@{/clients/api_key_request/prod_history}">운영</a>
</li>
</ul>
<div class="tab active">
@@ -49,7 +49,7 @@
<tbody>
<tr th:each="request, status : ${requests}">
<td th:text="${status.index + 1}">1</td>
<td><a th:href="@{/myapikey/api_key_request/detail(id=${request.id})}" class="btn btn-sm btn-primary"
<td><a th:href="@{/clients/api_key_request/detail(id=${request.id})}" class="btn btn-sm btn-primary"
th:text="'[' + ${request.clientName} + '] ' + ${request.type.description}"></a></td>
<td th:text="${request.approval.approvalStatus.description}">1</td>
<td th:text="${#temporals.format(request.approval.createdDate, 'yyyy-MM-dd')}"></td>
@@ -85,7 +85,7 @@
<tbody>
<tr th:each="request, status : ${requests}">
<td th:text="${status.index + 1}">1</td>
<td><a th:href="@{/myapikey/api_key_request/detail(id=${request.id})}" class="btn btn-sm btn-primary" th:text="${request.clientName}"></a></td>
<td><a th:href="@{/clients/api_key_request/detail(id=${request.id})}" class="btn btn-sm btn-primary" th:text="${request.clientName}"></a></td>
<td th:text="${request.approval.approvalStatus}">1</td>
<td th:text="${#temporals.format(request.approval.createdDate, 'yyyy-MM-dd')}"></td>
<td th:text="${#temporals.format(request.approval.approvalDate, 'yyyy-MM-dd')}"></td>
@@ -98,7 +98,7 @@
</div>
</div>
<div class="pagination" th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}"></div>
<form name="listForm" th:action="@{/myapikey/api_key_request/history}" method="get">
<form name="listForm" th:action="@{/clients/api_key_request/history}" method="get">
<input type="hidden" id="page" name="page"/>
</form>
</div>
@@ -112,7 +112,7 @@
<script>
function fn_select_page(pageNo) {
document.listForm.page.value = pageNo;
document.listForm.action = '[[@{/myapikey/api_key_request/history}]]';
document.listForm.action = '[[@{/clients/api_key_request/history}]]';
document.listForm.submit();
}
</script>
@@ -231,7 +231,7 @@
신청 취소
</button>
<!-- List Button (gray) -->
<a th:href="@{/myapikey}" class="dt-btn-gray">
<a th:href="@{/clients}" class="dt-btn-gray">
목록
</a>
</div>
@@ -289,7 +289,7 @@
$('.loading-overlay').show();
$.ajax({
url: '/myapikey/api_key_request/cancel',
url: '/clients/api_key_request/cancel',
type: 'POST',
data: { id: requestId },
dataType: 'json',
@@ -299,7 +299,7 @@
}).done(function (response) {
if (response.success) {
alert(response.message || '신청이 취소되었습니다.');
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
window.location.href = /*[[@{/clients}]]*/ '/clients';
} else {
alert(response.message || '신청 취소 중 오류가 발생했습니다.');
$('.loading-overlay').hide();
@@ -25,7 +25,7 @@
<div class="detail-wrap">
<!-- Title Bar -->
<div class="board-header">
<h2 class="board-title">인증키 정보</h2>
<h2 class="board-title">클라이언트 정보</h2>
<span class="board-desc">등록된 앱의 상세 정보를 확인할 수 있습니다.</span>
</div>
@@ -188,11 +188,11 @@
<!-- Bottom Navigation Actions -->
<div class="dt-actions" style="justify-content: flex-end; gap: 11px; margin-top: 30px;">
<a th:href="@{/myapikey}" class="dt-btn-gray">목록</a>
<a th:href="@{/clients}" class="dt-btn-gray">목록</a>
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red"
th:data-client-id="${apiKey.clientid}" onclick="deleteApiKeyFromButton(this)">API 이용 해지</button>
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-blue"
th:href="@{/myapikey/modify/step1(clientId=${apiKey.clientid})}">수정</a>
th:href="@{/clients/modify/step1(clientId=${apiKey.clientid})}">변경 신청</a>
</div>
</div><!-- /detail-wrap -->
@@ -218,43 +218,48 @@
);
}
// Show password prompt for viewing client secret (최초 1회 노출 + 서버측 물리 삭제)
// Client Secret 조회 진입 — 확인 후 조회 (본인 확인은 step-up 2FA)
function showPasswordPrompt() {
customPopups.showPasswordInput({
title: 'Client Secret 조회',
message: '보안을 위해 비밀번호를 입력해주세요.<br>조회 즉시 값은 영구 삭제됩니다.',
onConfirm: function (password) {
$.ajax({
url: /*[[@{/myapikey/credential/reveal-secret}]]*/ '/myapikey/credential/reveal-secret',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID, password: password }),
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
}
}).done(function (response) {
if (response.success) {
customPopups.hidePasswordInput();
// 서버가 반환한 secret을 화면에 1회 주입
$('#revealedSecretValue').text(response.secret);
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
$('#hiddenSecretBox').hide();
$('#revealedSecretBox').fadeIn(300);
} else if (response.alreadyRevealed) {
customPopups.hidePasswordInput();
showLostKeyGuide();
} else {
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
}
}).fail(function () {
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
});
},
onCancel: function () {
// User cancelled - do nothing
customPopups.showConfirm(
'Client Secret은 <strong>지금 한 번만</strong> 조회할 수 있으며,<br>조회 즉시 값은 <strong>영구 삭제</strong>됩니다.<br><br>조회하시겠습니까?',
function (confirmed) {
if (!confirmed) {
return;
}
doRevealSecret();
}
);
}
// Secret 조회 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
function doRevealSecret() {
$.ajax({
url: /*[[@{/clients/credential/reveal-secret}]]*/ '/clients/credential/reveal-secret',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID }),
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
}
}).done(function (response) {
if (response.success) {
// 서버가 반환한 secret을 화면에 1회 주입
$('#revealedSecretValue').text(response.secret);
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
$('#hiddenSecretBox').hide();
$('#revealedSecretBox').fadeIn(300);
} else if (response.alreadyRevealed) {
showLostKeyGuide();
} else {
customPopups.showAlert(response.message || 'Client Secret 조회에 실패했습니다.');
}
}).fail(function (jqXHR) {
if (isStepUpRequired(jqXHR)) {
requireStepUp('/clients/credential/reveal-secret', function () { doRevealSecret(); });
return;
}
customPopups.showAlert('오류가 발생했습니다. 다시 시도해주세요.');
});
}
@@ -307,7 +312,7 @@
$('.loading-overlay').show();
$.ajax({
url: /*[[@{/myapikey/api_key_delete}]]*/ '/myapikey/api_key_delete',
url: /*[[@{/clients/api_key_delete}]]*/ '/clients/api_key_delete',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ clientId: clientId }),
@@ -320,11 +325,11 @@
return;
}
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function () {
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
window.location.href = /*[[@{/clients}]]*/ '/clients';
});
}).fail(function (jqXHR, textStatus, errorThrown) {
if (isStepUpRequired(jqXHR)) {
requireStepUp('/myapikey/api_key_delete', function () { doDeleteApiKey(clientId); });
requireStepUp('/clients/api_key_delete', function () { doDeleteApiKey(clientId); });
return;
}
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
@@ -56,7 +56,7 @@
<div class="s2-selection-container">
<main class="s2-content-area">
<form id="apiSelectorForm" method="post" th:action="@{${formAction}}" class="s2-form"
th:attr="data-save-action=@{${saveAction}}, data-api-required=${!#strings.contains(formAction, '/myapikey/register')}">
th:attr="data-save-action=@{${saveAction}}, data-api-required=${!#strings.contains(formAction, '/clients/register')}">
<!-- Header filter: Search and Select All -->
<div class="s2-filter-header">
@@ -449,7 +449,7 @@
}
$('.loading-overlay').show();
$.ajax({
url: '/myapikey/api_key_request',
url: '/clients/api_key_request',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(requestData)
@@ -47,6 +47,7 @@
<li><a th:href="@{/partnership}">피드백/개선요청</a></li>
</ul>
</li>
<li><a href="#" class="nav-link">API Status</a></li>
</ul>
</nav>
@@ -82,7 +83,7 @@
<a th:href="@{/users}"><i class="fas fa-users"></i>개발자 관리</a>
</li>
<li sec:authorize="hasRole('ROLE_APP')">
<a th:href="@{/myapikey}"><i class="fas fa-key"></i>API 신청 관리</a>
<a th:href="@{/clients}"><i class="fas fa-key"></i>API 신청 관리</a>
<a th:href="@{/webhook}" sec:authorize="hasRole('ROLE_WEBHOOK')"><i class="fas fa-bell"></i>Webhook 관리</a>
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
</li>
@@ -228,6 +229,10 @@
<li><a th:href="@{/partnership}">사업 제휴 문의</a></li>
</ul>
</li>
<!-- API Status -->
<li class="drawer-menu-item">
<a href="#" class="drawer-menu-btn">API Status</a>
</li>
<!-- 마이페이지 (Authenticated Only) -->
<li class="drawer-menu-item has-submenu" sec:authorize="isAuthenticated()">
@@ -239,7 +244,7 @@
</button>
<ul class="drawer-submenu">
<li sec:authorize="hasRole('ROLE_CORP_MANAGER')"><a th:href="@{/users}">개발자 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/myapikey}">API 신청 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/clients}">API 신청 관리</a></li>
<li sec:authorize="hasRole('ROLE_WEBHOOK')"><a th:href="@{/webhook}">Webhook 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li>
<li><a th:href="@{/mypage}">내 정보 관리</a></li>
@@ -55,7 +55,7 @@
class="service-nav__item"
sec:authorize="hasRole('ROLE_CORP_MANAGER')">개발자 관리</a>
<a th:href="@{/myapikey}"
<a th:href="@{/clients}"
th:classappend="${activeMenu == 'apiKey'} ? 'service-nav__item--active' : ''"
class="service-nav__item">API 신청 관리</a>
@@ -20,7 +20,7 @@
<!-- Modal Body -->
<div class="modal-body">
<p id="passwordPopupMessage" style="text-align: center; margin-bottom: 24px; color: #64748B;">
<p id="passwordPopupMessage" style="text-align: center; margin-bottom: 24px; color: #64748B; word-break: keep-all;">
계속하려면 비밀번호를 입력해주세요.
</p>