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