Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfc859c9cf | |||
| 24c669b0be | |||
| 3eedc522e2 | |||
| a361cc3dec | |||
| 9981459691 | |||
| a2878f9cee | |||
| a6807a37c2 | |||
| 1f10dda993 | |||
| 6c10ae12c7 | |||
| 01c4a80182 | |||
| a5611cf775 | |||
| c733e6b200 | |||
| a2813f391c | |||
| f221c20ece | |||
| 4a3b5b43f3 | |||
| 8de0d94063 | |||
| 57d4efb4eb | |||
| c865359819 |
@@ -0,0 +1,294 @@
|
|||||||
|
package com.eactive.apim.portal.apps.apis.filter;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||||
|
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.TextNode;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 테스트베드(/api/call-api) 감사(audit) 로그 기록기.
|
||||||
|
*
|
||||||
|
* <p>logback 의 {@code eapim.portal.apitester.audit} 로거(전용 파일, 1년 보관)로 기록한다.
|
||||||
|
* 요청 1건당 REQ/RES 두 줄을 같은 auditId 로 남긴다.</p>
|
||||||
|
*
|
||||||
|
* <p>마스킹 정책:</p>
|
||||||
|
* <ul>
|
||||||
|
* <li>secret 계열 키(client_secret, password, api_key, authorization 등)의 값은 전체 마스킹</li>
|
||||||
|
* <li>그 외 파라미터/JSON 값은 앞 일부만 남기고 마스킹</li>
|
||||||
|
* <li>JSON 이 아닌 본문은 전체 길이(byte)와 앞 {@value #NON_JSON_PREVIEW_LENGTH}글자만 남기고 마스킹</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class ApiTesterAuditLogger {
|
||||||
|
|
||||||
|
private static final Logger auditLogger = LoggerFactory.getLogger("eapim.portal.apitester.audit");
|
||||||
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
/** 값 전체를 마스킹할 키(소문자 비교) */
|
||||||
|
private static final Set<String> SECRET_KEYS = new HashSet<>(Arrays.asList(
|
||||||
|
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
|
||||||
|
"api_key", "apikey", "access_token", "refresh_token", "authorization"));
|
||||||
|
|
||||||
|
/** 감사 로그에 남길 주요 요청 헤더 화이트리스트 */
|
||||||
|
private static final String[] AUDIT_HEADERS = {
|
||||||
|
"content-type", "accept", "referer", "origin", "x-forwarded-for",
|
||||||
|
"original-api-id", "authorization"};
|
||||||
|
|
||||||
|
/** 마스킹된 JSON 본문 로그 최대 길이(초과분 절단) — 대용량 본문의 로그 파일 비대화 방지 */
|
||||||
|
private static final int JSON_LOG_MAX_LENGTH = 2000;
|
||||||
|
|
||||||
|
/** JSON 이 아닌 본문의 노출 프리뷰 글자 수 */
|
||||||
|
private static final int NON_JSON_PREVIEW_LENGTH = 8;
|
||||||
|
|
||||||
|
private ApiTesterAuditLogger() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** REQ/RES 두 줄을 연결하는 짧은 감사 ID */
|
||||||
|
public static String newAuditId() {
|
||||||
|
return UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 수신 시점 기록. 감사 로그 실패가 프록시 동작을 막지 않도록 예외는 삼킨다.
|
||||||
|
*
|
||||||
|
* @param targetUrl original-url 헤더 값 (없으면 null)
|
||||||
|
* @param gatewayMode 게이트웨이 모드명 (판별 전이면 "-")
|
||||||
|
* @param tokenRequest OAuth 토큰 발급 요청 여부
|
||||||
|
* @param body 이미 읽어 둔 요청 본문 (없으면 null/빈 문자열)
|
||||||
|
*/
|
||||||
|
public static void logRequest(String auditId, HttpServletRequest request, String targetUrl,
|
||||||
|
String gatewayMode, boolean tokenRequest, String body) {
|
||||||
|
try {
|
||||||
|
StringBuilder sb = new StringBuilder(256);
|
||||||
|
sb.append("REQ [").append(auditId).append(']');
|
||||||
|
sb.append(" ip=").append(HttpRequestUtil.getClientIpAddress(request));
|
||||||
|
sb.append(" proxied=").append(HttpRequestUtil.isProxied(request));
|
||||||
|
sb.append(" user=").append(currentUser());
|
||||||
|
sb.append(" method=").append(request.getMethod());
|
||||||
|
sb.append(" mode=").append(gatewayMode);
|
||||||
|
sb.append(" token=").append(tokenRequest);
|
||||||
|
sb.append(" target=").append(targetUrl == null ? "-" : maskQueryValues(sanitize(targetUrl)));
|
||||||
|
sb.append(" ua=\"").append(sanitize(request.getHeader("User-Agent"))).append('"');
|
||||||
|
sb.append(" headers=").append(buildHeaderSummary(request));
|
||||||
|
sb.append(" body=").append(buildBodySummary(request.getContentType(), tokenRequest, body));
|
||||||
|
auditLogger.info(sb.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
auditLogger.warn("REQ [{}] 감사 로그 기록 실패: {}", auditId, e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 처리 완료 시점 기록. type 은 처리 분기(TOKEN_GW/TOKEN_MOCK/SAMPLE/GW/MOCK 등). */
|
||||||
|
public static void logResult(String auditId, int status, String type, long elapsedMillis) {
|
||||||
|
auditLogger.info("RES [{}] status={} type={} elapsedMs={}", auditId, status, type, elapsedMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 요청 정보 구성
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/** 로그인 사용자 식별자(마스킹). 미인증이면 anonymous. */
|
||||||
|
private static String currentUser() {
|
||||||
|
try {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getName())) {
|
||||||
|
return "anonymous";
|
||||||
|
}
|
||||||
|
String name = auth.getName();
|
||||||
|
return name.contains("@") ? StringMaskingUtil.maskEmail(name) : partialMask(name);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 화이트리스트 헤더만 {k:"v"} 형태로 요약. secret 계열 헤더 값은 마스킹. */
|
||||||
|
private static String buildHeaderSummary(HttpServletRequest request) {
|
||||||
|
StringBuilder sb = new StringBuilder("{");
|
||||||
|
boolean first = true;
|
||||||
|
for (String name : AUDIT_HEADERS) {
|
||||||
|
String value = request.getHeader(name);
|
||||||
|
if (value == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!first) {
|
||||||
|
sb.append(", ");
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
sb.append(name).append(":\"").append(maskHeaderValue(name, sanitize(value))).append('"');
|
||||||
|
}
|
||||||
|
return sb.append('}').toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authorization 등 인증 헤더는 스킴만 남기고 토큰부 마스킹. */
|
||||||
|
private static String maskHeaderValue(String name, String value) {
|
||||||
|
if (!SECRET_KEYS.contains(name.toLowerCase())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
int space = value.indexOf(' ');
|
||||||
|
if (space > 0) {
|
||||||
|
return value.substring(0, space) + " " + partialMask(value.substring(space + 1).trim());
|
||||||
|
}
|
||||||
|
return partialMask(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 본문 마스킹
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private static String buildBodySummary(String contentType, boolean tokenRequest, String body) {
|
||||||
|
if (body == null || body.isEmpty()) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
// 토큰 발급: form 필드 단위 마스킹 (client_secret 전체 마스킹)
|
||||||
|
if (tokenRequest) {
|
||||||
|
return "\"" + maskFormBody(body) + "\"";
|
||||||
|
}
|
||||||
|
// 일반 요청: JSON 이면 값 단위 부분 마스킹, 그 외(비 JSON)는 길이 + 프리뷰만
|
||||||
|
if (contentType != null && contentType.toLowerCase().contains("json")) {
|
||||||
|
String maskedJson = tryMaskJson(body);
|
||||||
|
if (maskedJson != null) {
|
||||||
|
return maskedJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nonJsonSummary(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** k=v&k=v 형태 본문의 값 단위 마스킹. secret 키는 전체 마스킹. */
|
||||||
|
private static String maskFormBody(String body) {
|
||||||
|
StringBuilder sb = new StringBuilder(body.length());
|
||||||
|
String[] pairs = body.split("&");
|
||||||
|
for (int i = 0; i < pairs.length; i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
sb.append('&');
|
||||||
|
}
|
||||||
|
int eq = pairs[i].indexOf('=');
|
||||||
|
if (eq < 0) {
|
||||||
|
sb.append(partialMask(pairs[i]));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String key = pairs[i].substring(0, eq);
|
||||||
|
String value = pairs[i].substring(eq + 1);
|
||||||
|
sb.append(key).append('=');
|
||||||
|
sb.append(SECRET_KEYS.contains(key.toLowerCase()) ? "*****" : partialMask(value));
|
||||||
|
}
|
||||||
|
return sanitize(sb.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL 쿼리스트링 값 단위 마스킹 (경로는 그대로). */
|
||||||
|
private static String maskQueryValues(String url) {
|
||||||
|
int qs = url.indexOf('?');
|
||||||
|
if (qs < 0) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return url.substring(0, qs) + "?" + maskFormBody(url.substring(qs + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON 파싱 성공 시 값 단위 마스킹 문자열, 실패 시 null. */
|
||||||
|
private static String tryMaskJson(String body) {
|
||||||
|
try {
|
||||||
|
JsonNode masked = maskJsonNode(OBJECT_MAPPER.readTree(body));
|
||||||
|
String out = OBJECT_MAPPER.writeValueAsString(masked);
|
||||||
|
if (out.length() > JSON_LOG_MAX_LENGTH) {
|
||||||
|
out = out.substring(0, JSON_LOG_MAX_LENGTH) + "...(truncated)";
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON 트리의 leaf 값을 재귀적으로 마스킹. secret 키 필드는 전체 마스킹. */
|
||||||
|
private static JsonNode maskJsonNode(JsonNode node) {
|
||||||
|
if (node.isObject()) {
|
||||||
|
ObjectNode obj = (ObjectNode) node;
|
||||||
|
Iterator<String> names = obj.fieldNames();
|
||||||
|
Set<String> fieldNames = new HashSet<>();
|
||||||
|
while (names.hasNext()) {
|
||||||
|
fieldNames.add(names.next());
|
||||||
|
}
|
||||||
|
for (String field : fieldNames) {
|
||||||
|
if (SECRET_KEYS.contains(field.toLowerCase())) {
|
||||||
|
obj.set(field, TextNode.valueOf("*****"));
|
||||||
|
} else {
|
||||||
|
obj.set(field, maskJsonNode(obj.get(field)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
if (node.isArray()) {
|
||||||
|
ArrayNode arr = (ArrayNode) node;
|
||||||
|
for (int i = 0; i < arr.size(); i++) {
|
||||||
|
arr.set(i, maskJsonNode(arr.get(i)));
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
if (node.isNull() || node.isMissingNode()) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
return TextNode.valueOf(partialMask(node.asText()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 비 JSON 본문: 전체 길이(byte)와 앞 몇 글자만 노출. */
|
||||||
|
private static String nonJsonSummary(String body) {
|
||||||
|
int bytes = body.getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
String preview = body.length() <= NON_JSON_PREVIEW_LENGTH
|
||||||
|
? body : body.substring(0, NON_JSON_PREVIEW_LENGTH);
|
||||||
|
return "(non-json,bytes=" + bytes + ",preview=\"" + sanitize(preview) + "***\")";
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 공통 helper
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/** 앞 일부(최대 4자)만 남기고 마스킹. 2자 이하는 전체 마스킹. */
|
||||||
|
private static String partialMask(String value) {
|
||||||
|
if (value == null || value.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int len = value.length();
|
||||||
|
if (len <= 2) {
|
||||||
|
return stars(len);
|
||||||
|
}
|
||||||
|
int visible = Math.min(4, Math.max(1, len / 3));
|
||||||
|
return value.substring(0, visible) + "***";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stars(int count) {
|
||||||
|
char[] arr = new char[count];
|
||||||
|
Arrays.fill(arr, '*');
|
||||||
|
return new String(arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 제어문자·개행·따옴표를 치환해 한 줄 로그 형식을 보존. */
|
||||||
|
private static String sanitize(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder(value.length());
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
char c = value.charAt(i);
|
||||||
|
if (c == '"') {
|
||||||
|
sb.append('\'');
|
||||||
|
} else if (c == '\r' || c == '\n' || c == '\t') {
|
||||||
|
sb.append(' ');
|
||||||
|
} else if (c < 0x20) {
|
||||||
|
sb.append('?');
|
||||||
|
} else {
|
||||||
|
sb.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,8 +68,16 @@ public class ApiTesterFilter implements Filter {
|
|||||||
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
|
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
|
||||||
String url = httpServletRequest.getHeader("original-url");
|
String url = httpServletRequest.getHeader("original-url");
|
||||||
|
|
||||||
|
// 감사 로그: 요청 1건당 REQ/RES 두 줄을 같은 auditId 로 남긴다 (전용 파일, 1년 보관)
|
||||||
|
String auditId = ApiTesterAuditLogger.newAuditId();
|
||||||
|
long auditStart = System.currentTimeMillis();
|
||||||
|
String auditType = "-";
|
||||||
|
|
||||||
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
|
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
|
||||||
if (url == null || url.trim().isEmpty()) {
|
if (url == null || url.trim().isEmpty()) {
|
||||||
|
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, null, "-", false, null);
|
||||||
|
ApiTesterAuditLogger.logResult(auditId, HttpServletResponse.SC_BAD_REQUEST, "BAD_REQUEST",
|
||||||
|
System.currentTimeMillis() - auditStart);
|
||||||
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}");
|
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -84,16 +92,15 @@ public class ApiTesterFilter implements Filter {
|
|||||||
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|
||||||
|| url.contains(gatewayProperty.tokenPath());
|
|| url.contains(gatewayProperty.tokenPath());
|
||||||
|
|
||||||
|
// 본문은 한 번만 읽어 프록시 forward 와 감사 로그에 함께 사용 (GET 이면 빈 문자열)
|
||||||
|
String requestBody = readBody(httpServletRequest);
|
||||||
|
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url, gatewayMode.name(), tokenRequest, requestBody);
|
||||||
|
|
||||||
if (tokenRequest) {
|
if (tokenRequest) {
|
||||||
StringBuilder sb = new StringBuilder();
|
String body = requestBody;
|
||||||
BufferedReader reader = httpServletRequest.getReader();
|
|
||||||
String line;
|
|
||||||
while ((line = reader.readLine()) != null) {
|
|
||||||
sb.append(line);
|
|
||||||
}
|
|
||||||
String body = sb.toString();
|
|
||||||
|
|
||||||
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
|
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
|
||||||
|
auditType = "TOKEN_MOCK";
|
||||||
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
|
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
|
||||||
Map<String, String> params = new HashMap<>();
|
Map<String, String> params = new HashMap<>();
|
||||||
String[] pairs = body.split("&");
|
String[] pairs = body.split("&");
|
||||||
@@ -106,7 +113,7 @@ public class ApiTesterFilter implements Filter {
|
|||||||
String scope = params.getOrDefault("scope", "default");
|
String scope = params.getOrDefault("scope", "default");
|
||||||
|
|
||||||
String token = "{\n" +
|
String token = "{\n" +
|
||||||
" \"access_token\": \"djbank_gw_sample_token\",\n" +
|
" \"access_token\": \"" + escapeJson(gatewayProperty.mockAccessToken()) + "\",\n" +
|
||||||
" \"token_type\": \"bearer\",\n" +
|
" \"token_type\": \"bearer\",\n" +
|
||||||
" \"expires_in\": 86400,\n" +
|
" \"expires_in\": 86400,\n" +
|
||||||
" \"scope\": \""+scope +"\",\n" +
|
" \"scope\": \""+scope +"\",\n" +
|
||||||
@@ -117,6 +124,7 @@ public class ApiTesterFilter implements Filter {
|
|||||||
response.getWriter().println(token);
|
response.getWriter().println(token);
|
||||||
} else {
|
} else {
|
||||||
// GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만)
|
// GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만)
|
||||||
|
auditType = "TOKEN_GW";
|
||||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||||
Map<String, String> headers = new HashMap<>();
|
Map<String, String> headers = new HashMap<>();
|
||||||
headers.put("Content-Type", "application/x-www-form-urlencoded");
|
headers.put("Content-Type", "application/x-www-form-urlencoded");
|
||||||
@@ -133,6 +141,7 @@ public class ApiTesterFilter implements Filter {
|
|||||||
|
|
||||||
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
|
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
|
||||||
if (apiSpecInfoDto == null) {
|
if (apiSpecInfoDto == null) {
|
||||||
|
auditType = "SPEC_NOT_FOUND";
|
||||||
writeJson(response, HttpServletResponse.SC_NOT_FOUND,
|
writeJson(response, HttpServletResponse.SC_NOT_FOUND,
|
||||||
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
|
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
|
||||||
return;
|
return;
|
||||||
@@ -142,6 +151,7 @@ public class ApiTesterFilter implements Filter {
|
|||||||
|
|
||||||
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
|
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
|
||||||
if (responseType == null || responseType.equalsIgnoreCase("sample")) {
|
if (responseType == null || responseType.equalsIgnoreCase("sample")) {
|
||||||
|
auditType = "SAMPLE";
|
||||||
response.setContentType("application/json");
|
response.setContentType("application/json");
|
||||||
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
|
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
|
||||||
return;
|
return;
|
||||||
@@ -151,6 +161,7 @@ public class ApiTesterFilter implements Filter {
|
|||||||
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
|
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
|
||||||
// - mock : ApiSpecInfo.mockUrl (기존 동작)
|
// - mock : ApiSpecInfo.mockUrl (기존 동작)
|
||||||
boolean gw = "gw".equalsIgnoreCase(responseType);
|
boolean gw = "gw".equalsIgnoreCase(responseType);
|
||||||
|
auditType = gw ? "GW" : "MOCK";
|
||||||
|
|
||||||
Map<String, String> headers = new HashMap<>();
|
Map<String, String> headers = new HashMap<>();
|
||||||
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
|
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
|
||||||
@@ -160,6 +171,10 @@ public class ApiTesterFilter implements Filter {
|
|||||||
}
|
}
|
||||||
headers.remove("original-url");
|
headers.remove("original-url");
|
||||||
headers.remove("original-api-id");
|
headers.remove("original-api-id");
|
||||||
|
// readBody()가 개행을 제거해 원본 Content-Length와 실제 전송 바이트가 달라질 수 있고,
|
||||||
|
// WebLogic HTTP 클라이언트는 이 불일치를 IOException으로 처리하므로 length 계열 헤더는
|
||||||
|
// 전달하지 않는다(HttpURLConnection이 실제 바이트 수로 재설정).
|
||||||
|
headers.keySet().removeIf(k -> "content-length".equalsIgnoreCase(k) || "transfer-encoding".equalsIgnoreCase(k));
|
||||||
|
|
||||||
String targetUri;
|
String targetUri;
|
||||||
Map<String, String[]> paramMap;
|
Map<String, String[]> paramMap;
|
||||||
@@ -179,7 +194,7 @@ public class ApiTesterFilter implements Filter {
|
|||||||
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())) {
|
||||||
responseStr = apiSender.requestPost(targetUri, headers, paramMap, readBody(httpServletRequest));
|
responseStr = apiSender.requestPost(targetUri, headers, paramMap, requestBody);
|
||||||
} else {
|
} else {
|
||||||
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
|
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
|
||||||
}
|
}
|
||||||
@@ -202,6 +217,9 @@ public class ApiTesterFilter implements Filter {
|
|||||||
logger.error("테스트베드 프록시 처리 오류", e);
|
logger.error("테스트베드 프록시 처리 오류", 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 {
|
||||||
|
ApiTesterAuditLogger.logResult(auditId, ((HttpServletResponse) response).getStatus(), auditType,
|
||||||
|
System.currentTimeMillis() - auditStart);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.eactive.apim.portal.apps.app.controller;
|
package com.eactive.apim.portal.apps.app.controller;
|
||||||
|
|
||||||
import com.eactive.apim.portal.apprequest.entity.AppRequest;
|
import com.eactive.apim.portal.apprequest.entity.AppRequest;
|
||||||
|
import com.eactive.apim.portal.approval.statemachine.InvalidApprovalTransitionException;
|
||||||
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.apps.apiservice.dto.ApiGroupSearch;
|
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||||
@@ -178,7 +179,6 @@ public class MyAppController {
|
|||||||
|
|
||||||
model.addAttribute("apiKey", apiKey);
|
model.addAttribute("apiKey", apiKey);
|
||||||
model.addAttribute("secretAvailable", secretAvailable);
|
model.addAttribute("secretAvailable", secretAvailable);
|
||||||
model.addAttribute("authType", "OAuth2");
|
|
||||||
|
|
||||||
return new ModelAndView(CREDENTIAL_DETAIL);
|
return new ModelAndView(CREDENTIAL_DETAIL);
|
||||||
}
|
}
|
||||||
@@ -217,9 +217,17 @@ public class MyAppController {
|
|||||||
appServiceFacade.cancelApiRequest(id, SecurityUtil.getPortalAuthenticatedUser().getPortalOrg());
|
appServiceFacade.cancelApiRequest(id, SecurityUtil.getPortalAuthenticatedUser().getPortalOrg());
|
||||||
result.put("success", true);
|
result.put("success", true);
|
||||||
result.put("message", "신청이 취소되었습니다.");
|
result.put("message", "신청이 취소되었습니다.");
|
||||||
} catch (Exception e) {
|
} catch (InvalidApprovalTransitionException e) {
|
||||||
result.put("success", false);
|
result.put("success", false);
|
||||||
result.put("message", "신청 취소 중 오류가 발생했습니다: " + e.getMessage());
|
result.put("message", "내부 결재가 진행 중이라 신청을 취소할 수 없습니다. 취소가 필요한 경우 관리자에게 문의해 주세요.");
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
log.error("API Key 신청 취소 중 GW 차단 실패. id={}", id, e);
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", e.getMessage());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("API Key 신청 취소 실패. id={}", id, e);
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "신청 취소 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ public class AppServiceFacade {
|
|||||||
private final ApiServiceHelper apiServiceHelper;
|
private final ApiServiceHelper apiServiceHelper;
|
||||||
private final FileService fileService;
|
private final FileService fileService;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final AdminGatewayClient adminGatewayClient;
|
||||||
|
|
||||||
public List<ClientDTO> getApikeyList(PortalOrg portalOrg) {
|
public List<ClientDTO> getApikeyList(PortalOrg portalOrg) {
|
||||||
|
|
||||||
@@ -68,9 +69,13 @@ public class AppServiceFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<AppRequest> getPendingApiKeyList(PortalOrg portalOrg) {
|
public List<AppRequest> getPendingApiKeyList(PortalOrg portalOrg) {
|
||||||
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, Arrays.asList(AppRequestType.NEW, AppRequestType.MODIFY, AppRequestType.DELETE),
|
List<AppRequestType> types = Arrays.asList(AppRequestType.NEW, AppRequestType.MODIFY, AppRequestType.DELETE);
|
||||||
|
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, types,
|
||||||
Arrays.asList(new ProcessingState(), new RequestedState()));
|
Arrays.asList(new ProcessingState(), new RequestedState()));
|
||||||
|
|
||||||
|
// 승인정보(approval) 없는 신청도 목록에 노출한다. (사용자가 직접 삭제 가능)
|
||||||
|
appRequests.addAll(appRequestRepository.findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
|
||||||
|
|
||||||
return appRequests;
|
return appRequests;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +139,23 @@ public class AppServiceFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void cancelApiRequest(String id, PortalOrg portalOrg) {
|
public void cancelApiRequest(String id, PortalOrg portalOrg) {
|
||||||
appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(approvalService::cancelAppApproval);
|
appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(request -> {
|
||||||
|
if (request.getApproval() == null) {
|
||||||
|
// 승인정보 없는 신청은 결재 워크플로우가 없으므로 즉시 삭제.
|
||||||
|
// 단, GW에 클라이언트가 존재할 수 있으므로 차단(appstatus=0)+리로드를 먼저 수행하고
|
||||||
|
// 실패 시 삭제를 중단한다. (/api_key_delete 와 동일한 순서)
|
||||||
|
if (StringUtils.isNotBlank(request.getClientId())) {
|
||||||
|
try {
|
||||||
|
adminGatewayClient.blockClient(request.getClientId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("게이트웨이 차단 처리에 실패하여 삭제를 중단했습니다. 잠시 후 다시 시도해 주세요.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appRequestRepository.delete(request);
|
||||||
|
} else {
|
||||||
|
approvalService.cancelAppApproval(request);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -164,9 +185,15 @@ public class AppServiceFacade {
|
|||||||
Map<String, ApiServiceDTO> mainIconsMap = apiServiceHelper.getMainIconsFromServiceDtos(apiServices);
|
Map<String, ApiServiceDTO> mainIconsMap = apiServiceHelper.getMainIconsFromServiceDtos(apiServices);
|
||||||
|
|
||||||
for (String apiId : apiList) {
|
for (String apiId : apiList) {
|
||||||
ApiServiceDTO serviceDTO = mainIconsMap.get(apiId);
|
// 신청 이후 API 스펙/그룹이 삭제된 경우 null 가능
|
||||||
ApiSpecInfoDto spec = apiService.selectDetail(apiId);
|
ApiSpecInfoDto spec = apiService.selectDetail(apiId);
|
||||||
spec.setService(serviceDTO.getGroupName());
|
if (spec == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ApiServiceDTO serviceDTO = mainIconsMap.get(apiId);
|
||||||
|
if (serviceDTO != null) {
|
||||||
|
spec.setService(serviceDTO.getGroupName());
|
||||||
|
}
|
||||||
appRequest.getApiSpecList().add(spec);
|
appRequest.getApiSpecList().add(spec);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
@@ -5,6 +5,7 @@ import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.web.csrf.CsrfToken;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@@ -25,6 +26,8 @@ import java.util.Optional;
|
|||||||
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
|
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
|
||||||
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
|
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
|
||||||
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
|
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
|
||||||
|
* <li>GET /api/session/ping - 익명 세션 keepalive (로그인/회원가입 페이지)</li>
|
||||||
|
* <li>GET /api/session/csrf - 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망)</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -109,6 +112,37 @@ public class SessionApiController {
|
|||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 익명(비로그인) 페이지용 세션 keepalive ping.
|
||||||
|
* 요청이 기존 세션에 접근하는 것만으로 컨테이너의 세션 비활성 타이머가 리셋되어
|
||||||
|
* 익명 세션(세션 저장 CSRF 토큰, 회원가입 본인인증 상태 포함)이 유지된다.
|
||||||
|
* 세션이 없으면 새로 만들지 않는다.
|
||||||
|
*/
|
||||||
|
@GetMapping("/ping")
|
||||||
|
public ResponseEntity<Void> ping(HttpServletRequest request) {
|
||||||
|
request.getSession(false);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망).
|
||||||
|
* 세션 만료로 토큰이 사라진 경우 CsrfFilter가 새 토큰을 생성하고,
|
||||||
|
* 이 핸들러가 토큰 값을 읽는 시점에 새 세션에 저장된다(LazyCsrfTokenRepository).
|
||||||
|
* 회원가입 절차는 세션에 본인인증 상태를 들고 있어 토큰 재발급만으로는 복구가 안 되므로
|
||||||
|
* 로그인 페이지 안전망으로만 사용한다.
|
||||||
|
*/
|
||||||
|
@GetMapping("/csrf")
|
||||||
|
public ResponseEntity<Map<String, String>> csrfToken(HttpServletRequest request) {
|
||||||
|
CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
|
||||||
|
Map<String, String> result = new HashMap<>();
|
||||||
|
if (token != null) {
|
||||||
|
result.put("headerName", token.getHeaderName());
|
||||||
|
result.put("parameterName", token.getParameterName());
|
||||||
|
result.put("token", token.getToken());
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
|
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
|
||||||
* 예: 192.168.240.178 → 192.168.***.178
|
* 예: 192.168.240.178 → 192.168.***.178
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ import java.util.Optional;
|
|||||||
public class UserSessionService {
|
public class UserSessionService {
|
||||||
|
|
||||||
private static final String PROPERTY_GROUP = "Portal";
|
private static final String PROPERTY_GROUP = "Portal";
|
||||||
private static final String PROPERTY_NAME = "session.timeout.minutes";
|
|
||||||
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
|
/** 세션 타임아웃(분) 고정값. application.yml(timeout: 10m)·weblogic.xml(timeout-secs 600)과 동일하게 유지한다. */
|
||||||
|
public static final int SESSION_TIMEOUT_MINUTES = 10;
|
||||||
|
|
||||||
/** 세션 유지(타임아웃 무시) 기능 활성화 여부 프로퍼티 (true/false). 비운영 전용 — prod 가드는 상위(GlobalControllerAdvice)에서 적용 */
|
/** 세션 유지(타임아웃 무시) 기능 활성화 여부 프로퍼티 (true/false). 비운영 전용 — prod 가드는 상위(GlobalControllerAdvice)에서 적용 */
|
||||||
private static final String KEEPALIVE_PROPERTY_NAME = "session.keepalive.enabled";
|
private static final String KEEPALIVE_PROPERTY_NAME = "session.keepalive.enabled";
|
||||||
@@ -120,21 +121,10 @@ public class UserSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DB(PortalProperty)에서 세션 타임아웃 값 조회 (분)
|
* 세션 타임아웃(분). {@value #SESSION_TIMEOUT_MINUTES}분 고정 (DB property 관리 폐지).
|
||||||
*/
|
*/
|
||||||
public int getSessionTimeoutMinutes() {
|
public int getSessionTimeoutMinutes() {
|
||||||
String value = portalPropertyService.getOrCreateProperty(
|
return SESSION_TIMEOUT_MINUTES;
|
||||||
PROPERTY_GROUP,
|
|
||||||
PROPERTY_NAME,
|
|
||||||
DEFAULT_TIMEOUT_MINUTES,
|
|
||||||
"세션 타임아웃 시간 (분)"
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
return Integer.parseInt(value.trim());
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
log.warn("세션 타임아웃 값 파싱 실패: {}, 기본값 {}분 사용", value, DEFAULT_TIMEOUT_MINUTES);
|
|
||||||
return Integer.parseInt(DEFAULT_TIMEOUT_MINUTES);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ public class GlobalControllerAdvice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 화면 세션 타이머 기준이 되는 타임아웃(분). PortalProperty(Portal/session.timeout.minutes)에서 조회.
|
* 화면 세션 타이머 기준이 되는 타임아웃(분). 10분 고정 (UserSessionService.SESSION_TIMEOUT_MINUTES).
|
||||||
*/
|
*/
|
||||||
@ModelAttribute("sessionTimeoutMinutes")
|
@ModelAttribute("sessionTimeoutMinutes")
|
||||||
public int sessionTimeoutMinutes() {
|
public int sessionTimeoutMinutes() {
|
||||||
|
|||||||
+10
-1
@@ -7,6 +7,7 @@ import java.util.Map;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.config.PortalProperties;
|
import com.eactive.apim.portal.config.PortalProperties;
|
||||||
import com.eactive.apim.portal.file.exception.InvalidFileException;
|
import com.eactive.apim.portal.file.exception.InvalidFileException;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -54,7 +55,15 @@ public class PortalGlobalExceptionHandler {
|
|||||||
|
|
||||||
@ExceptionHandler(value = AccessDeniedException.class)
|
@ExceptionHandler(value = AccessDeniedException.class)
|
||||||
public ModelAndView handleAccessDeniedException(HttpServletRequest request, AccessDeniedException ex) {
|
public ModelAndView handleAccessDeniedException(HttpServletRequest request, AccessDeniedException ex) {
|
||||||
return new ModelAndView("redirect:/login");
|
// 미로그인 사용자는 로그인 페이지로 유도, 로그인 상태에서의 권한 부족은 오류 안내 페이지로 표시한다.
|
||||||
|
if (!SecurityUtil.isAuthenticated()) {
|
||||||
|
return new ModelAndView("redirect:/login");
|
||||||
|
}
|
||||||
|
log.warn("접근 권한 없음: loginId={}, uri={}", SecurityUtil.getCurrentLoginId(), request.getRequestURI());
|
||||||
|
ModelAndView modelAndView = new ModelAndView("error");
|
||||||
|
modelAndView.addObject("errorTitle", "페이지 접근 권한이 없습니다.");
|
||||||
|
modelAndView.addObject("errorDescription", "해당 페이지를 이용할 수 있는 권한이 없는 계정입니다.\n권한이 필요한 경우 관리자에게 문의해 주세요.");
|
||||||
|
return modelAndView;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(value = PortalRedirectException.class)
|
@ExceptionHandler(value = PortalRedirectException.class)
|
||||||
|
|||||||
+2
-2
@@ -122,8 +122,8 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
|||||||
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
||||||
clientIp, request.getHeader("User-Agent"));
|
clientIp, request.getHeader("User-Agent"));
|
||||||
|
|
||||||
// 물리 세션 타임아웃을 DB property(Portal/session.timeout.minutes)와 일치시킴.
|
// 물리 세션 타임아웃 10분 고정. yml(timeout: 10m)·weblogic.xml(timeout-secs 600)과 동일 값이지만
|
||||||
// yml/weblogic.xml 기본값을 이 세션에 대해 override → 물리=논리 단일화(CSRF 수명 포함).
|
// 컨테이너 설정(콘솔 override 등)과 무관하게 보장하기 위해 명시 적용 → 물리=논리 단일화(CSRF 수명 포함).
|
||||||
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
|
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
|
||||||
|
|
||||||
// 로그인 성공 시 세션 정보 로깅
|
// 로그인 성공 시 세션 정보 로깅
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.config;
|
|||||||
|
|
||||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
|
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||||
import org.aspectj.lang.JoinPoint;
|
import org.aspectj.lang.JoinPoint;
|
||||||
import org.aspectj.lang.annotation.Aspect;
|
import org.aspectj.lang.annotation.Aspect;
|
||||||
import org.aspectj.lang.annotation.Before;
|
import org.aspectj.lang.annotation.Before;
|
||||||
@@ -32,7 +33,7 @@ public class PortalConfigLog {
|
|||||||
String query = request.getQueryString();
|
String query = request.getQueryString();
|
||||||
String ip = HttpRequestUtil.getClientIpAddress(request);
|
String ip = HttpRequestUtil.getClientIpAddress(request);
|
||||||
|
|
||||||
String user = SecurityUtil.getCurrentLoginId();
|
String user = StringMaskingUtil.maskEmail(SecurityUtil.getCurrentLoginId());
|
||||||
|
|
||||||
String message = String.format("Request: %s %s?%s from %s by %s", method, path, query, ip, user);
|
String message = String.format("Request: %s %s?%s from %s by %s", method, path, query, ip, user);
|
||||||
Logger logger = LoggerFactory.getLogger(joinPoint.getTarget().getClass());
|
Logger logger = LoggerFactory.getLogger(joinPoint.getTarget().getClass());
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ public class PortalConfigSecurity {
|
|||||||
// 운영(prod/eapim/devportal)은 동일 호스트(IP:PORT)에 여러 서비스가 떠 있어
|
// 운영(prod/eapim/devportal)은 동일 호스트(IP:PORT)에 여러 서비스가 떠 있어
|
||||||
// 쿠키가 호스트 단위로 공유·과포화되면서 XSRF-TOKEN 쿠키가 누락 → 로그인 403이 발생했다.
|
// 쿠키가 호스트 단위로 공유·과포화되면서 XSRF-TOKEN 쿠키가 누락 → 로그인 403이 발생했다.
|
||||||
// 기존 클라이언트(X-XSRF-TOKEN 헤더, _csrf 파라미터)와 호환되도록 헤더명을 고정한다.
|
// 기존 클라이언트(X-XSRF-TOKEN 헤더, _csrf 파라미터)와 호환되도록 헤더명을 고정한다.
|
||||||
|
// 세션에 저장되므로 CSRF 토큰 수명은 세션 타임아웃(10분)과 동일하다.
|
||||||
HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository();
|
HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository();
|
||||||
csrfTokenRepository.setHeaderName("X-XSRF-TOKEN");
|
csrfTokenRepository.setHeaderName("X-XSRF-TOKEN");
|
||||||
|
|
||||||
|
|||||||
+10
@@ -32,6 +32,7 @@ public class DjbTestbedGatewayProperty {
|
|||||||
public static final String KEY_TIMEOUT_SEC = "djb.gateway.timeout";
|
public static final String KEY_TIMEOUT_SEC = "djb.gateway.timeout";
|
||||||
public static final String KEY_USE_PROXY = "djb.gateway.use-proxy";
|
public static final String KEY_USE_PROXY = "djb.gateway.use-proxy";
|
||||||
public static final String KEY_TOKEN_USE_PROXY = "djb.gateway.token-use-proxy";
|
public static final String KEY_TOKEN_USE_PROXY = "djb.gateway.token-use-proxy";
|
||||||
|
public static final String KEY_MOCK_ACCESS_TOKEN = "djb.gateway.mock-access-token";
|
||||||
|
|
||||||
public static final String DEFAULT_BASE_URL = "PortalMock";
|
public static final String DEFAULT_BASE_URL = "PortalMock";
|
||||||
public static final String DEFAULT_TIMEOUT_SEC = "10";
|
public static final String DEFAULT_TIMEOUT_SEC = "10";
|
||||||
@@ -47,6 +48,9 @@ public class DjbTestbedGatewayProperty {
|
|||||||
/** PortalMock 모드에서 사용하는 포털 기존 mock 토큰 경로. */
|
/** PortalMock 모드에서 사용하는 포털 기존 mock 토큰 경로. */
|
||||||
public static final String PORTAL_MOCK_TOKEN_PATH = "/api/v1/oauth/token";
|
public static final String PORTAL_MOCK_TOKEN_PATH = "/api/v1/oauth/token";
|
||||||
|
|
||||||
|
/** PortalMock 모드 토큰 응답의 access_token 기본값. */
|
||||||
|
public static final String DEFAULT_MOCK_ACCESS_TOKEN = "djbank_gw_sample_token";
|
||||||
|
|
||||||
public String baseUrl() {
|
public String baseUrl() {
|
||||||
return resolve(KEY_BASE_URL, DEFAULT_BASE_URL,
|
return resolve(KEY_BASE_URL, DEFAULT_BASE_URL,
|
||||||
"GW Base URL. 문자열 \"PortalMock\" 이면 ApiTesterFilter 가 mock 토큰 반환");
|
"GW Base URL. 문자열 \"PortalMock\" 이면 ApiTesterFilter 가 mock 토큰 반환");
|
||||||
@@ -86,6 +90,12 @@ public class DjbTestbedGatewayProperty {
|
|||||||
"테스트베드 토큰 발급 시 서버 프록시(/api/call-api) 사용 여부(true/false). false 이면 브라우저에서 직접 호출."), true);
|
"테스트베드 토큰 발급 시 서버 프록시(/api/call-api) 사용 여부(true/false). false 이면 브라우저에서 직접 호출."), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** PortalMock 모드 토큰 응답에 넣을 access_token 값. */
|
||||||
|
public String mockAccessToken() {
|
||||||
|
return resolve(KEY_MOCK_ACCESS_TOKEN, DEFAULT_MOCK_ACCESS_TOKEN,
|
||||||
|
"PortalMock 모드 토큰 발급 응답의 access_token 값");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 프로퍼티 값을 boolean 으로 해석. 레거시 {@code Y/N} 값도 자동 변환한다.
|
* 프로퍼티 값을 boolean 으로 해석. 레거시 {@code Y/N} 값도 자동 변환한다.
|
||||||
* {@code true}/{@code Y} → true, {@code false}/{@code N} → false, 그 외/null → {@code def}.
|
* {@code true}/{@code Y} → true, {@code false}/{@code N} → false, 그 외/null → {@code def}.
|
||||||
|
|||||||
+25
-8
@@ -25,8 +25,11 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* testbed spec(swagger.json/yaml)에 AUTHTYPE 기반 securityScheme 를 주입하고,
|
* testbed spec(swagger.json/yaml)에 AUTHTYPE 기반 securityScheme 를 주입하고,
|
||||||
* 서버 sentinel({@link DjbTestbedSpecServerRewriter#SERVER_SENTINEL})을 API SPEC 설정(responseType)에
|
* 서버 sentinel({@link DjbTestbedSpecServerRewriter#SERVER_SENTINEL})을 실주소로 치환해 반환한다.
|
||||||
* 따른 실주소로 치환해 반환한다.
|
* <ul>
|
||||||
|
* <li>{@code swagger.json}/{@code swagger.yaml} : 외부 공개/다운로드용 — 항상 GW 주소로 치환</li>
|
||||||
|
* <li>{@code swagger-ui.json} : Swagger UI 전용 — API SPEC 설정(responseType: sample/mock/gw)에 따라 치환</li>
|
||||||
|
* </ul>
|
||||||
* {@code default-token-api-spec} 은 클래스패스 기본 spec 을 그대로 반환(auth enrich 대상 외).
|
* {@code default-token-api-spec} 은 클래스패스 기본 spec 을 그대로 반환(auth enrich 대상 외).
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@@ -45,22 +48,34 @@ public class DjbTestbedSpecController {
|
|||||||
|
|
||||||
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
|
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
public ResponseEntity<String> swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
|
public ResponseEntity<String> swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
|
||||||
String json = buildSpecJson(id, request);
|
String json = buildSpecJson(id, request, true);
|
||||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
|
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
|
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
|
||||||
public ResponseEntity<String> swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
|
public ResponseEntity<String> swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
|
||||||
String json = buildSpecJson(id, request);
|
String json = buildSpecJson(id, request, true);
|
||||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
|
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null. */
|
/** Swagger UI 전용 spec — 서버 주소를 responseType(sample/mock/gw) 설정에 따라 치환. */
|
||||||
private String buildSpecJson(String id, HttpServletRequest request) throws IOException {
|
@GetMapping(value = "/{id}/swagger-ui.json", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
public ResponseEntity<String> swaggerForUi(@PathVariable String id, HttpServletRequest request) throws IOException {
|
||||||
|
String json = buildSpecJson(id, request, false);
|
||||||
|
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null.
|
||||||
|
* @param alwaysGateway true 면 항상 GW 주소 치환(다운로드용), false 면 responseType 설정 기반(UI용)
|
||||||
|
*/
|
||||||
|
private String buildSpecJson(String id, HttpServletRequest request, boolean alwaysGateway) throws IOException {
|
||||||
if (DEFAULT_TOKEN_API_ID.equals(id)) {
|
if (DEFAULT_TOKEN_API_ID.equals(id)) {
|
||||||
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
|
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
|
||||||
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
|
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
|
||||||
return serverRewriter.rewriteServer(content, null, request);
|
return alwaysGateway
|
||||||
|
? serverRewriter.rewriteServerToGateway(content, request)
|
||||||
|
: serverRewriter.rewriteServer(content, null, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
|
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
|
||||||
@@ -70,6 +85,8 @@ public class DjbTestbedSpecController {
|
|||||||
|
|
||||||
DjbAuthType authType = authService.resolveAuthType(id);
|
DjbAuthType authType = authService.resolveAuthType(id);
|
||||||
String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType);
|
String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType);
|
||||||
return serverRewriter.rewriteServer(enriched, spec.get(), request);
|
return alwaysGateway
|
||||||
|
? serverRewriter.rewriteServerToGateway(enriched, request)
|
||||||
|
: serverRewriter.rewriteServer(enriched, spec.get(), request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -50,6 +50,22 @@ public class DjbTestbedSpecServerRewriter {
|
|||||||
return specJson.replace(SERVER_SENTINEL, base);
|
return specJson.replace(SERVER_SENTINEL, base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sentinel → GW 주소({@link DjbTestbedGatewayProperty#resolveApiBaseUrl}) 치환한 spec JSON 반환.
|
||||||
|
* responseType 을 무시하고 항상 GW 기준으로 치환한다 — 외부 공개/다운로드용 spec(swagger.json/yaml)
|
||||||
|
* 단일 기준. (Swagger UI 표시용은 {@link #rewriteServer} 의 responseType 분기를 그대로 사용.)
|
||||||
|
*/
|
||||||
|
public String rewriteServerToGateway(String specJson, HttpServletRequest request) {
|
||||||
|
if (specJson == null || !specJson.contains(SERVER_SENTINEL)) {
|
||||||
|
return specJson;
|
||||||
|
}
|
||||||
|
String base = stripTrailingSlash(gatewayProperty.resolveApiBaseUrl(originOf(request)));
|
||||||
|
if (!StringUtils.hasText(base)) {
|
||||||
|
return specJson; // 실주소 미확정 시 sentinel 유지
|
||||||
|
}
|
||||||
|
return specJson.replace(SERVER_SENTINEL, base);
|
||||||
|
}
|
||||||
|
|
||||||
/** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */
|
/** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */
|
||||||
public String toYaml(String specJson) {
|
public String toYaml(String specJson) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ server:
|
|||||||
servlet:
|
servlet:
|
||||||
context-path: /
|
context-path: /
|
||||||
session:
|
session:
|
||||||
# 물리 세션 타임아웃은 DB PortalProperty(Portal/session.timeout.minutes)로 관리한다.
|
# 세션 타임아웃 10분 고정 (DB property 관리 폐지).
|
||||||
# 로그인 성공 시 PortalAuthenticationSuccessHandler 가
|
# WebLogic 배포 시에는 weblogic.xml <timeout-secs>600 이 동일 값을 적용한다.
|
||||||
# session.setMaxInactiveInterval(session.timeout.minutes * 60) 으로 적용 → 물리=논리 일치.
|
# CSRF 토큰은 세션에 저장(HttpSessionCsrfTokenRepository)되므로 수명도 이 값과 동일하다.
|
||||||
# 익명/로그인 전 세션은 컨테이너 기본값으로 fallback (weblogic.xml <timeout-secs>1800).
|
timeout: 10m
|
||||||
# timeout: 10m
|
|
||||||
cookie:
|
cookie:
|
||||||
name: JSESSIONID_PORTAL
|
name: JSESSIONID_PORTAL
|
||||||
encoding:
|
encoding:
|
||||||
|
|||||||
@@ -56,6 +56,19 @@
|
|||||||
</encoder>
|
</encoder>
|
||||||
</appender>
|
</appender>
|
||||||
|
|
||||||
|
<!-- API 테스트베드(/api/call-api) 감사 로그: 요청지/헤더/마스킹된 본문 기록, 1년(365일) 보관 -->
|
||||||
|
<appender name="API_TESTER_AUDIT" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<file>${LOG_PATH}/apitester-audit.log</file>
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||||
|
<fileNamePattern>${LOG_PATH}/backup/apitester-audit.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||||
|
<maxFileSize>200MB</maxFileSize>
|
||||||
|
<maxHistory>365</maxHistory>
|
||||||
|
</rollingPolicy>
|
||||||
|
<encoder>
|
||||||
|
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %msg%n</pattern>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||||
<level>${CONSOLE_EFFECTIVE_LEVEL}</level>
|
<level>${CONSOLE_EFFECTIVE_LEVEL}</level>
|
||||||
@@ -68,6 +81,10 @@
|
|||||||
<appender-ref ref="HTTP_SESSION" />
|
<appender-ref ref="HTTP_SESSION" />
|
||||||
</logger>
|
</logger>
|
||||||
|
|
||||||
|
<logger name="eapim.portal.apitester.audit" level="INFO" additivity="false">
|
||||||
|
<appender-ref ref="API_TESTER_AUDIT" />
|
||||||
|
</logger>
|
||||||
|
|
||||||
|
|
||||||
<root level="INFO">
|
<root level="INFO">
|
||||||
<appender-ref ref="ROLLING"/>
|
<appender-ref ref="ROLLING"/>
|
||||||
|
|||||||
@@ -693,7 +693,7 @@ hr {
|
|||||||
--accent-orange: #FF6B6B;
|
--accent-orange: #FF6B6B;
|
||||||
--accent-green: #6BCF7F;
|
--accent-green: #6BCF7F;
|
||||||
--accent-purple: #A78BFA;
|
--accent-purple: #A78BFA;
|
||||||
--accent-color:#efdcb2;
|
--accent-color: #efdcb2;
|
||||||
--accent-light: #E9F9FF;
|
--accent-light: #E9F9FF;
|
||||||
--text-dark: #1A1A2E;
|
--text-dark: #1A1A2E;
|
||||||
--text-gray: #64748B;
|
--text-gray: #64748B;
|
||||||
@@ -1490,7 +1490,7 @@ body.design-survey-active .global-header {
|
|||||||
.nav-menu {
|
.nav-menu {
|
||||||
display: flex;
|
display: flex;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
gap: 36px;
|
gap: 28px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -1545,7 +1545,7 @@ body.design-survey-active .global-header {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-gray);
|
color: var(--text-gray);
|
||||||
padding: 8px 0;
|
padding: 8px;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
}
|
}
|
||||||
@@ -6028,7 +6028,7 @@ select.form-control {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.board-table .board-table-row .row-cell--title .notice-title-link:hover {
|
.board-table .board-table-row .row-cell--title .notice-title-link:hover {
|
||||||
text-decoration: underline;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.board-table .board-table-row .row-cell--title .notice-title-link .notice-number {
|
.board-table .board-table-row .row-cell--title .notice-title-link .notice-number {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -6521,7 +6521,8 @@ select.form-control {
|
|||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
.pagination .pagination-btn i, .pagination .pagination-btn svg {
|
.pagination .pagination-btn i,
|
||||||
|
.pagination .pagination-btn svg {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
@@ -10226,32 +10227,38 @@ body.index-page-body {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
.api-market-container {
|
||||||
.api-market-container .service-sidebar {
|
/*
|
||||||
position: fixed;
|
.service-sidebar {
|
||||||
left: 0;
|
@media (max-width: 768px) {
|
||||||
top: 0;
|
position: fixed;
|
||||||
height: 100vh;
|
left: 0;
|
||||||
width: 280px;
|
top: 0;
|
||||||
margin: 0;
|
height: 100vh;
|
||||||
z-index: 1000;
|
width: 280px;
|
||||||
transform: translateX(-100%);
|
margin: 0;
|
||||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
z-index: 1000;
|
||||||
background: #ffffff;
|
transform: translateX(-100%);
|
||||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
padding: 80px 24px 30px;
|
background: #ffffff;
|
||||||
}
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
|
||||||
.api-market-container .service-sidebar.mobile-open {
|
padding: 80px 24px 30px; // Clear header height if any
|
||||||
transform: translateX(0);
|
|
||||||
}
|
&.mobile-open {
|
||||||
.api-market-container .service-sidebar .service-nav {
|
transform: translateX(0);
|
||||||
flex-direction: column !important;
|
}
|
||||||
gap: 16px !important;
|
|
||||||
border: none !important;
|
.service-nav {
|
||||||
padding: 0 !important;
|
flex-direction: column !important;
|
||||||
box-shadow: none !important;
|
gap: 16px !important;
|
||||||
background: transparent !important;
|
border: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-market-hero {
|
.api-market-hero {
|
||||||
@@ -10471,6 +10478,7 @@ body.index-page-body {
|
|||||||
|
|
||||||
.api-market-content {
|
.api-market-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -10491,35 +10499,7 @@ body.index-page-body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.api-mobile-toggle {
|
.api-mobile-toggle {
|
||||||
display: none;
|
display: none !important;
|
||||||
position: fixed;
|
|
||||||
bottom: 24px;
|
|
||||||
right: 24px;
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
color: #FFFFFF;
|
|
||||||
font-size: 24px;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
|
|
||||||
z-index: 1051;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
background-color: #0049b4;
|
|
||||||
}
|
|
||||||
.api-mobile-toggle:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 16px 40px rgba(75, 155, 255, 0.2);
|
|
||||||
}
|
|
||||||
.api-mobile-toggle:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.api-mobile-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-market-header {
|
.api-market-header {
|
||||||
@@ -10815,6 +10795,124 @@ body.index-page-body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#testbed-tab {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
#testbed-tab #swagger-ui .info {
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testbed-app-panel {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 24px;
|
||||||
|
background: #FFFFFF;
|
||||||
|
border: 1px solid #E2E8F0;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
.testbed-app-panel .testbed-app-panel__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.testbed-app-panel .testbed-app-panel__title {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1A1A2E;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.testbed-app-panel .testbed-app-panel__title::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 4px;
|
||||||
|
height: 15px;
|
||||||
|
background: #0049b4;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.testbed-app-panel .testbed-app-panel__desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #64748B;
|
||||||
|
}
|
||||||
|
.testbed-app-panel .testbed-app-field {
|
||||||
|
position: relative;
|
||||||
|
max-width: 380px;
|
||||||
|
}
|
||||||
|
.testbed-app-panel .testbed-app-field::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
top: 50%;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
margin-top: -6px;
|
||||||
|
border-right: 2px solid #64748B;
|
||||||
|
border-bottom: 2px solid #64748B;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.testbed-app-panel #apps {
|
||||||
|
width: 100%;
|
||||||
|
height: 46px;
|
||||||
|
padding: 0 42px 0 16px;
|
||||||
|
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1A1A2E;
|
||||||
|
background: #F8FAFC;
|
||||||
|
border: 1px solid #E2E8F0;
|
||||||
|
border-radius: 8px;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.testbed-app-panel #apps:hover:not(:disabled) {
|
||||||
|
border-color: #94A3B8;
|
||||||
|
background: #FFFFFF;
|
||||||
|
}
|
||||||
|
.testbed-app-panel #apps:focus {
|
||||||
|
outline: none;
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-color: #0049b4;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 73, 180, 0.14);
|
||||||
|
}
|
||||||
|
.testbed-app-panel #apps:disabled {
|
||||||
|
color: #94A3B8;
|
||||||
|
background: #F8FAFC;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.testbed-app-panel .testbed-app-notice {
|
||||||
|
margin: 16px 0 0;
|
||||||
|
padding: 11px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #64748B;
|
||||||
|
background: #EFF6FF;
|
||||||
|
border: 1px solid rgba(0, 73, 180, 0.18);
|
||||||
|
border-left: 3px solid #0049b4;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.testbed-app-panel .testbed-app-notice::before {
|
||||||
|
content: "ⓘ ";
|
||||||
|
color: #0049b4;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.testbed-app-panel {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.testbed-app-panel .testbed-app-field {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.api-overview-card {
|
.api-overview-card {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@@ -10907,12 +11005,12 @@ body.index-page-body {
|
|||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
.api-detail-card .detail-content {
|
.api-detail-card .detail-content:not(.editor-content) {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #64748B;
|
color: #64748B;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
.api-detail-card .detail-content pre {
|
.api-detail-card .detail-content:not(.editor-content) pre {
|
||||||
background-color: #F8FAFC;
|
background-color: #F8FAFC;
|
||||||
border: 1px solid #E2E8F0;
|
border: 1px solid #E2E8F0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -10920,14 +11018,14 @@ body.index-page-body {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.api-detail-card .detail-content pre code {
|
.api-detail-card .detail-content:not(.editor-content) pre code {
|
||||||
font-family: "Courier New", monospace;
|
font-family: "Courier New", monospace;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #1A1A2E;
|
color: #1A1A2E;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
.api-detail-card .detail-content table {
|
.api-detail-card .detail-content:not(.editor-content) table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
@@ -10936,10 +11034,10 @@ body.index-page-body {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.api-detail-card .detail-content table thead {
|
.api-detail-card .detail-content:not(.editor-content) table thead {
|
||||||
background-color: #EFF6FF;
|
background-color: #EFF6FF;
|
||||||
}
|
}
|
||||||
.api-detail-card .detail-content table th {
|
.api-detail-card .detail-content:not(.editor-content) table th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -10948,28 +11046,28 @@ body.index-page-body {
|
|||||||
border-bottom: 2px solid #E2E8F0;
|
border-bottom: 2px solid #E2E8F0;
|
||||||
background-color: rgba(0, 73, 180, 0.05);
|
background-color: rgba(0, 73, 180, 0.05);
|
||||||
}
|
}
|
||||||
.api-detail-card .detail-content table th:not(:last-child) {
|
.api-detail-card .detail-content:not(.editor-content) table th:not(:last-child) {
|
||||||
border-right: 1px solid #E2E8F0;
|
border-right: 1px solid #E2E8F0;
|
||||||
}
|
}
|
||||||
.api-detail-card .detail-content table td {
|
.api-detail-card .detail-content:not(.editor-content) table td {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #64748B;
|
color: #64748B;
|
||||||
border-bottom: 1px solid #E2E8F0;
|
border-bottom: 1px solid #E2E8F0;
|
||||||
}
|
}
|
||||||
.api-detail-card .detail-content table td:not(:last-child) {
|
.api-detail-card .detail-content:not(.editor-content) table td:not(:last-child) {
|
||||||
border-right: 1px solid #E2E8F0;
|
border-right: 1px solid #E2E8F0;
|
||||||
}
|
}
|
||||||
.api-detail-card .detail-content table tr {
|
.api-detail-card .detail-content:not(.editor-content) table tr {
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
.api-detail-card .detail-content table tr:last-child td {
|
.api-detail-card .detail-content:not(.editor-content) table tr:last-child td {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
.api-detail-card .detail-content table tr:hover {
|
.api-detail-card .detail-content:not(.editor-content) table tr:hover {
|
||||||
background-color: rgba(0, 73, 180, 0.02);
|
background-color: rgba(0, 73, 180, 0.02);
|
||||||
}
|
}
|
||||||
.api-detail-card .detail-content table tbody tr:nth-child(even) {
|
.api-detail-card .detail-content:not(.editor-content) table tbody tr:nth-child(even) {
|
||||||
background-color: rgba(248, 250, 252, 0.3);
|
background-color: rgba(248, 250, 252, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11076,27 +11174,31 @@ body.index-page-body {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.api-detail-card .detail-content {
|
.api-detail-card .detail-content.editor-content {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.api-detail-card .detail-content:not(.editor-content) {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
.api-detail-card .detail-content table {
|
.api-detail-card .detail-content:not(.editor-content) table {
|
||||||
min-width: 500px;
|
min-width: 500px;
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
.api-detail-card .detail-content table th,
|
.api-detail-card .detail-content:not(.editor-content) table th,
|
||||||
.api-detail-card .detail-content table td {
|
.api-detail-card .detail-content:not(.editor-content) table td {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.api-detail-card .detail-content pre {
|
.api-detail-card .detail-content:not(.editor-content) pre {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
.api-detail-card .detail-content pre code {
|
.api-detail-card .detail-content:not(.editor-content) pre code {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
word-break: normal;
|
word-break: normal;
|
||||||
@@ -11138,9 +11240,12 @@ body.index-page-body {
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
.api-market-content {
|
.api-market-content {
|
||||||
max-width: 100vw;
|
width: 100%;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
.api-card {
|
||||||
|
height: 285px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.login-page {
|
.login-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -17741,6 +17846,12 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
.detail-wrap .dt-cancel-notice {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
.detail-wrap .dt-actions {
|
.detail-wrap .dt-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -18364,6 +18475,14 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
color: #0a4ea3;
|
color: #0a4ea3;
|
||||||
border: 1px solid #b3d4ff;
|
border: 1px solid #b3d4ff;
|
||||||
}
|
}
|
||||||
|
.notice-type-badge.lg {
|
||||||
|
height: 28px;
|
||||||
|
width: auto;
|
||||||
|
min-width: 50px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.notice-detail-title {
|
.notice-detail-title {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
@@ -18421,22 +18540,27 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
.notice-attachment-link .attachment-filename {
|
.notice-attachment-link .attachment-filename {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #666;
|
color: #666;
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice-detail-incident-info {
|
.notice-detail-incident-info,
|
||||||
|
.notice-detail-affected-info {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
.notice-detail-incident-info .detail-table {
|
.notice-detail-incident-info .detail-table,
|
||||||
|
.notice-detail-affected-info .detail-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
border-top: none;
|
border-top: none;
|
||||||
border-bottom: 1px solid #e3e8f0;
|
border-bottom: 1px solid #e3e8f0;
|
||||||
}
|
}
|
||||||
.notice-detail-incident-info .detail-table tr {
|
.notice-detail-incident-info .detail-table tr,
|
||||||
|
.notice-detail-affected-info .detail-table tr {
|
||||||
border-bottom: 1px solid #e3e8f0;
|
border-bottom: 1px solid #e3e8f0;
|
||||||
}
|
}
|
||||||
.notice-detail-incident-info .detail-table th {
|
.notice-detail-incident-info .detail-table th,
|
||||||
|
.notice-detail-affected-info .detail-table th {
|
||||||
background: #f9f9f9;
|
background: #f9f9f9;
|
||||||
height: 43px;
|
height: 43px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -18444,12 +18568,114 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
.notice-detail-incident-info .detail-table td {
|
.notice-detail-incident-info .detail-table td,
|
||||||
padding-left: 19px;
|
.notice-detail-affected-info .detail-table td {
|
||||||
|
padding: 10px 19px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #000;
|
color: #000;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.notice-detail-incident-info .detail-table,
|
||||||
|
.notice-detail-affected-info .detail-table {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.notice-detail-incident-info .detail-table colgroup,
|
||||||
|
.notice-detail-affected-info .detail-table colgroup {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.notice-detail-incident-info .detail-table tbody,
|
||||||
|
.notice-detail-incident-info .detail-table tr,
|
||||||
|
.notice-detail-incident-info .detail-table th,
|
||||||
|
.notice-detail-incident-info .detail-table td,
|
||||||
|
.notice-detail-affected-info .detail-table tbody,
|
||||||
|
.notice-detail-affected-info .detail-table tr,
|
||||||
|
.notice-detail-affected-info .detail-table th,
|
||||||
|
.notice-detail-affected-info .detail-table td {
|
||||||
|
display: block;
|
||||||
|
width: 100% !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.notice-detail-incident-info .detail-table tr,
|
||||||
|
.notice-detail-affected-info .detail-table tr {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.notice-detail-incident-info .detail-table th,
|
||||||
|
.notice-detail-affected-info .detail-table th {
|
||||||
|
text-align: left;
|
||||||
|
background: #f8fafc;
|
||||||
|
height: auto;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.notice-detail-incident-info .detail-table td,
|
||||||
|
.notice-detail-affected-info .detail-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.affected-apis-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affected-apis-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affected-api-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 16px;
|
||||||
|
background-color: #f3f6fa;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555555;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.affected-api-badge strong {
|
||||||
|
color: #0a4ea3;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-affected-toggle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #0049b4;
|
||||||
|
border: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
align-self: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
.btn-affected-toggle:hover {
|
||||||
|
background-color: #003685;
|
||||||
|
}
|
||||||
|
.btn-affected-toggle .arrow {
|
||||||
|
font-size: 10px;
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.btn-affected-toggle.active .arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
.notice-detail-content {
|
.notice-detail-content {
|
||||||
padding: 20px 0 60px 0;
|
padding: 20px 0 60px 0;
|
||||||
@@ -18507,35 +18733,8 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-notice-list {
|
.btn-notice-list {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 247px;
|
width: 247px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
background: #2a69de;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.btn-notice-list {
|
|
||||||
width: auto;
|
|
||||||
min-width: 120px;
|
|
||||||
padding: 0 20px;
|
|
||||||
height: 48px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.btn-notice-list:hover {
|
|
||||||
background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
.btn-notice-list:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice-detail-card {
|
.notice-detail-card {
|
||||||
@@ -22637,12 +22836,39 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
}
|
}
|
||||||
.service-sidebar {
|
.service-sidebar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.service-nav {
|
.service-nav {
|
||||||
flex-direction: row;
|
flex-direction: row !important;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap !important;
|
||||||
justify-content: center;
|
justify-content: flex-start !important;
|
||||||
padding: 20px;
|
padding: 16px 20px !important;
|
||||||
|
overflow-x: auto !important;
|
||||||
|
-webkit-overflow-scrolling: touch !important;
|
||||||
|
gap: 24px !important;
|
||||||
|
border: 1px solid #e3e8f0 !important;
|
||||||
|
border-radius: 20px !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
background: #ffffff !important;
|
||||||
|
}
|
||||||
|
.service-nav::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.service-nav {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
/* IE and Edge */
|
||||||
|
scrollbar-width: none;
|
||||||
|
/* Firefox */
|
||||||
|
}
|
||||||
|
.service-nav .service-sidebar__profile,
|
||||||
|
.service-nav .service-sidebar__divider {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.service-nav .service-nav__item {
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
.service-hero,
|
.service-hero,
|
||||||
.oauth2-2legged__hero {
|
.oauth2-2legged__hero {
|
||||||
@@ -23249,7 +23475,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
}
|
}
|
||||||
.oauth2-2legged__prereq-grid {
|
.oauth2-2legged__prereq-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
.oauth2-2legged__prereq-card {
|
.oauth2-2legged__prereq-card {
|
||||||
@@ -23311,17 +23537,23 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
.oauth2-2legged__step-grid + .oauth2-2legged__step-grid {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
.oauth2-2legged__endpoint-box {
|
.oauth2-2legged__endpoint-box {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr auto;
|
grid-template-columns: 1fr;
|
||||||
align-items: center;
|
gap: 8px;
|
||||||
gap: 16px;
|
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border: 1px solid #E2E8F0;
|
border: 1px solid #E2E8F0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
.oauth2-2legged__endpoint-box .oauth2-2legged__method,
|
||||||
|
.oauth2-2legged__endpoint-box .oauth2-2legged__endpoint-content-type {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
.oauth2-2legged__method {
|
.oauth2-2legged__method {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 5px 14px;
|
padding: 5px 14px;
|
||||||
@@ -23448,6 +23680,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
background: #1A1A2E;
|
background: #1A1A2E;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
.oauth2-2legged__code-panel::before {
|
.oauth2-2legged__code-panel::before {
|
||||||
content: "";
|
content: "";
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -26,6 +26,9 @@
|
|||||||
|
|
||||||
var startTs = 0;
|
var startTs = 0;
|
||||||
var targetOpblock = null;
|
var targetOpblock = null;
|
||||||
|
// Execute 클릭 후에만 true. spec 로딩 등 실행 외 응답이 responseInterceptor 로
|
||||||
|
// 들어와도 그리드를 만들지 않기 위한 게이트 (실행 전 UI 미노출 보장).
|
||||||
|
var pendingExecute = false;
|
||||||
|
|
||||||
function now() {
|
function now() {
|
||||||
return (global.performance && performance.now) ? performance.now() : Date.now();
|
return (global.performance && performance.now) ? performance.now() : Date.now();
|
||||||
@@ -206,6 +209,7 @@
|
|||||||
|
|
||||||
// 네트워크/CORS 실패 렌더: responseInterceptor 로 오지 않고 store 에만 error 로 남는 케이스.
|
// 네트워크/CORS 실패 렌더: responseInterceptor 로 오지 않고 store 에만 error 로 남는 케이스.
|
||||||
function renderNetworkError(msg) {
|
function renderNetworkError(msg) {
|
||||||
|
pendingExecute = false;
|
||||||
var grid = gridFor(targetOpblock);
|
var grid = gridFor(targetOpblock);
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
grid.classList.remove("djb-empty");
|
grid.classList.remove("djb-empty");
|
||||||
@@ -250,6 +254,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
startTs = now();
|
startTs = now();
|
||||||
|
pendingExecute = true;
|
||||||
var grid = gridFor(targetOpblock);
|
var grid = gridFor(targetOpblock);
|
||||||
if (grid) {
|
if (grid) {
|
||||||
grid.classList.add("djb-empty");
|
grid.classList.add("djb-empty");
|
||||||
@@ -264,10 +269,13 @@
|
|||||||
*/
|
*/
|
||||||
function renderResponse(res) {
|
function renderResponse(res) {
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
// spec 로딩 응답(/…/swagger.json)은 responseInterceptor 로도 들어온다. 이때 렌더하면
|
// Execute 를 누른 적 없으면 무시 — spec 로딩(/…/swagger.json) 응답도
|
||||||
// 실행 전인데 응답 본문에 OpenAPI spec 이 그려지므로 무시한다. 사용자 API 호출은
|
// responseInterceptor 로 들어오는데, 이때 렌더하면 실행 전 응답 본문에
|
||||||
// 프록시(/api/call-api)로 나가므로 url 에 swagger.json 이 없다.
|
// OpenAPI spec 이 그려진다. url 검사는 버전에 따라 res.url 이 비어 무력화될
|
||||||
|
// 수 있어 실행 게이트를 1차 방어로 둔다.
|
||||||
|
if (!pendingExecute) return;
|
||||||
if (res.url && res.url.indexOf("swagger.json") !== -1) return;
|
if (res.url && res.url.indexOf("swagger.json") !== -1) return;
|
||||||
|
pendingExecute = false;
|
||||||
var ms = Math.max(0, Math.round(now() - startTs));
|
var ms = Math.max(0, Math.round(now() - startTs));
|
||||||
var status = res.status || 0;
|
var status = res.status || 0;
|
||||||
var statusText = res.statusText || "";
|
var statusText = res.statusText || "";
|
||||||
|
|||||||
@@ -12,5 +12,5 @@ html {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: #fafafa;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,7 +282,7 @@
|
|||||||
.board-table {
|
.board-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-top: 1px solid #c4c7c8;
|
border-top: 1px solid #c4c7c8;
|
||||||
|
|
||||||
&-wrapper {
|
&-wrapper {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin-bottom: $spacing-xl;
|
margin-bottom: $spacing-xl;
|
||||||
@@ -351,7 +351,7 @@
|
|||||||
@media (max-width: $breakpoint-sm) {
|
@media (max-width: $breakpoint-sm) {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: attr(data-label);
|
content: attr(data-label);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -364,9 +364,11 @@
|
|||||||
&--title {
|
&--title {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
|
|
||||||
@media (max-width: $breakpoint-sm) {
|
@media (max-width: $breakpoint-sm) {
|
||||||
&::before { display: none; }
|
&::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice-title-link {
|
.notice-title-link {
|
||||||
@@ -376,9 +378,9 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: underline;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile number prefix [1] [2] etc
|
// Mobile number prefix [1] [2] etc
|
||||||
@@ -813,6 +815,7 @@
|
|||||||
// 공지사항 모바일 디자인 - 검색창, 목록, 페이지네이션
|
// 공지사항 모바일 디자인 - 검색창, 목록, 페이지네이션
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@media (max-width: $breakpoint-sm) {
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
|
||||||
// 검색 필드 - Figma: 335px × 40px, border-radius 8px
|
// 검색 필드 - Figma: 335px × 40px, border-radius 8px
|
||||||
.search-field {
|
.search-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -995,7 +998,8 @@
|
|||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|
||||||
i, svg {
|
i,
|
||||||
|
svg {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
@@ -1019,4 +1023,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
--accent-orange: #FF6B6B;
|
--accent-orange: #FF6B6B;
|
||||||
--accent-green: #6BCF7F;
|
--accent-green: #6BCF7F;
|
||||||
--accent-purple: #A78BFA;
|
--accent-purple: #A78BFA;
|
||||||
--accent-color:#efdcb2;
|
--accent-color: #efdcb2;
|
||||||
--accent-light: #E9F9FF;
|
--accent-light: #E9F9FF;
|
||||||
|
|
||||||
// Base colors
|
// Base colors
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
--shadow-hover: 0 30px 60px -10px rgba(0, 73, 180, 0.2);
|
--shadow-hover: 0 30px 60px -10px rgba(0, 73, 180, 0.2);
|
||||||
|
|
||||||
// border
|
// border
|
||||||
--border-color : #E2E8F0;
|
--border-color: #E2E8F0;
|
||||||
|
|
||||||
//transition
|
//transition
|
||||||
--transition-smooth: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
--transition-smooth: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
@@ -225,19 +225,19 @@ body.design-survey-active {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-size:24px;
|
font-size: 24px;
|
||||||
|
|
||||||
.logo-text-bold {
|
.logo-text-bold {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
letter-spacing: -0.5px;
|
letter-spacing: -0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.logo-text-thin {
|
.logo-text-thin {
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
color: var(--secondary-color);
|
color: var(--secondary-color);
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -999,7 +999,7 @@ body.design-survey-active {
|
|||||||
.nav-menu {
|
.nav-menu {
|
||||||
display: flex;
|
display: flex;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
gap: 36px;
|
gap: 28px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
@@ -1064,7 +1064,7 @@ body.design-survey-active {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-gray);
|
color: var(--text-gray);
|
||||||
padding: 8px 0;
|
padding: 8px;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sidebar overrides for mobile toggle menu inside market
|
// Sidebar overrides for mobile toggle menu inside market
|
||||||
|
// Commented out to allow horizontal scrollbar on mobile screens as well
|
||||||
|
/*
|
||||||
.service-sidebar {
|
.service-sidebar {
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -52,6 +54,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hero Section
|
// Hero Section
|
||||||
@@ -311,6 +314,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
// padding-left: $spacing-sm;
|
// padding-left: $spacing-sm;
|
||||||
// padding-right: $spacing-sm;
|
// padding-right: $spacing-sm;
|
||||||
|
width: 100%;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -334,37 +338,7 @@
|
|||||||
|
|
||||||
// Mobile Menu Toggle
|
// Mobile Menu Toggle
|
||||||
.api-mobile-toggle {
|
.api-mobile-toggle {
|
||||||
display: none;
|
display: none !important;
|
||||||
position: fixed;
|
|
||||||
bottom: $spacing-lg;
|
|
||||||
right: $spacing-lg;
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
|
|
||||||
border-radius: $border-radius-circle;
|
|
||||||
border: none;
|
|
||||||
color: $white;
|
|
||||||
font-size: 24px;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: $shadow-lg;
|
|
||||||
z-index: $z-index-modal + 1;
|
|
||||||
transition: $transition-base;
|
|
||||||
background-color: $primary-blue;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: $shadow-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: $breakpoint-sm) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header Section
|
// Header Section
|
||||||
@@ -697,6 +671,139 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Testbed 탭: 앱 선택 패널과 Swagger UI 사이 여백 제거
|
||||||
|
#testbed-tab {
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
|
#swagger-ui .info {
|
||||||
|
margin: $spacing-lg 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Testbed 앱 선택 패널 (DJPGPT0001)
|
||||||
|
.testbed-app-panel {
|
||||||
|
margin: $spacing-sm 0 0;
|
||||||
|
padding: $spacing-lg;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $border-gray;
|
||||||
|
border-radius: $border-radius-lg;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
|
||||||
|
.testbed-app-panel__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $spacing-xs $spacing-sm;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testbed-app-panel__title {
|
||||||
|
position: relative;
|
||||||
|
padding-left: $spacing-md;
|
||||||
|
font-size: $font-size-base;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $text-dark;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 4px;
|
||||||
|
height: 15px;
|
||||||
|
background: $primary-blue;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.testbed-app-panel__desc {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $text-gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testbed-app-field {
|
||||||
|
position: relative;
|
||||||
|
max-width: 380px;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: $spacing-md;
|
||||||
|
top: 50%;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
margin-top: -6px;
|
||||||
|
border-right: 2px solid $text-gray;
|
||||||
|
border-bottom: 2px solid $text-gray;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#apps {
|
||||||
|
width: 100%;
|
||||||
|
height: 46px;
|
||||||
|
padding: 0 42px 0 $input-padding-x;
|
||||||
|
font-family: $font-family-primary;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $text-dark;
|
||||||
|
background: $gray-bg;
|
||||||
|
border: 1px solid $border-gray;
|
||||||
|
border-radius: $border-radius-md;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: $transition-fast;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: $text-light;
|
||||||
|
background: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
background: $white;
|
||||||
|
border-color: $primary-blue;
|
||||||
|
box-shadow: 0 0 0 3px rgba($primary-blue, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
color: $text-light;
|
||||||
|
background: $gray-bg;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.testbed-app-notice {
|
||||||
|
margin: $spacing-md 0 0;
|
||||||
|
padding: 11px $spacing-md;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
line-height: $line-height-normal;
|
||||||
|
color: $text-gray;
|
||||||
|
background: $light-bg;
|
||||||
|
border: 1px solid rgba($primary-blue, 0.18);
|
||||||
|
border-left: 3px solid $primary-blue;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "ⓘ ";
|
||||||
|
color: $primary-blue;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
padding: $spacing-md;
|
||||||
|
|
||||||
|
.testbed-app-field {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// API Overview Card (Flat Style)
|
// API Overview Card (Flat Style)
|
||||||
.api-overview-card {
|
.api-overview-card {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -803,7 +910,11 @@
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-content {
|
// [editor-content 정렬] Summernote(관리자) 작성 콘텐츠는 editor-content.css 가 전담 스타일링하므로
|
||||||
|
// 자체 테이블/폰트 스타일은 .editor-content 미적용 영역(샘플 pre/code)에만 건다.
|
||||||
|
// 과거 회귀: :not(.editor-content) 를 지워 .detail-content 로 되돌리고,
|
||||||
|
// mainApiDetail.html 의 editor-content 클래스 3곳을 제거하면 기존 스타일로 복원됨
|
||||||
|
.detail-content:not(.editor-content) {
|
||||||
font-size: $font-size-sm;
|
font-size: $font-size-sm;
|
||||||
color: $text-gray;
|
color: $text-gray;
|
||||||
line-height: $line-height-normal;
|
line-height: $line-height-normal;
|
||||||
@@ -1013,8 +1124,16 @@
|
|||||||
margin-bottom: $spacing-sm;
|
margin-bottom: $spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [editor-content 정렬] Summernote 콘텐츠는 editor-content.css 모바일 규칙 사용,
|
||||||
|
// 넘치는 표 대비 가로 스크롤만 부여. 과거 회귀 시 이 블록 삭제
|
||||||
|
.detail-content.editor-content {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
// Detail Content - 모바일에서 가로 스크롤 지원
|
// Detail Content - 모바일에서 가로 스크롤 지원
|
||||||
.detail-content {
|
// 과거 회귀: :not(.editor-content) 제거
|
||||||
|
.detail-content:not(.editor-content) {
|
||||||
font-size: $font-size-xs;
|
font-size: $font-size-xs;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
@@ -1097,7 +1216,11 @@
|
|||||||
|
|
||||||
// API Market Content - Mobile (화면 폭 제한)
|
// API Market Content - Mobile (화면 폭 제한)
|
||||||
.api-market-content {
|
.api-market-content {
|
||||||
max-width: 100vw;
|
width: 100%;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.api-card {
|
||||||
|
height: 285px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1232,6 +1232,14 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 내부 결재 진행중: 신청 취소 불가 안내
|
||||||
|
.dt-cancel-notice {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
// Footer Buttons layout
|
// Footer Buttons layout
|
||||||
.dt-actions {
|
.dt-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -158,6 +158,16 @@
|
|||||||
color: #0a4ea3;
|
color: #0a4ea3;
|
||||||
border: 1px solid #b3d4ff;
|
border: 1px solid #b3d4ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
&.lg {
|
||||||
|
height: 28px;
|
||||||
|
width: auto;
|
||||||
|
min-width: 50px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice-detail-title {
|
.notice-detail-title {
|
||||||
@@ -222,11 +232,13 @@
|
|||||||
.attachment-filename {
|
.attachment-filename {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #666;
|
color: #666;
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Incident Info Table
|
// Incident Info Table
|
||||||
.notice-detail-incident-info {
|
.notice-detail-incident-info,
|
||||||
|
.notice-detail-affected-info {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
|
||||||
.detail-table {
|
.detail-table {
|
||||||
@@ -250,11 +262,112 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
padding-left: 19px;
|
padding: 10px 19px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #000;
|
color: #000;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
colgroup {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody,
|
||||||
|
tr,
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
display: block;
|
||||||
|
width: 100% !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
background: #f8fafc;
|
||||||
|
height: auto;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Affected APIs Styling
|
||||||
|
.affected-apis-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affected-apis-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affected-api-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 16px;
|
||||||
|
background-color: #f3f6fa;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555555;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #0a4ea3;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-affected-toggle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #0049b4;
|
||||||
|
border: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
align-self: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
line-height: normal;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #003685;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
font-size: 10px;
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active .arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,37 +437,8 @@
|
|||||||
|
|
||||||
// List Button - Figma Design
|
// List Button - Figma Design
|
||||||
.btn-notice-list {
|
.btn-notice-list {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 247px;
|
width: 247px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
background: #2a69de;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: $transition-base;
|
|
||||||
|
|
||||||
@media (max-width: $breakpoint-md) {
|
|
||||||
width: auto;
|
|
||||||
min-width: 120px;
|
|
||||||
padding: 0 20px;
|
|
||||||
height: 48px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: darken(#2a69de, 10%);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notice detail card
|
// Notice detail card
|
||||||
|
|||||||
@@ -227,13 +227,42 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
|
|||||||
|
|
||||||
.service-sidebar {
|
.service-sidebar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-nav {
|
.service-nav {
|
||||||
flex-direction: row;
|
flex-direction: row !important;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap !important;
|
||||||
justify-content: center;
|
justify-content: flex-start !important;
|
||||||
padding: 20px;
|
padding: 16px 20px !important;
|
||||||
|
overflow-x: auto !important;
|
||||||
|
-webkit-overflow-scrolling: touch !important;
|
||||||
|
gap: 24px !important;
|
||||||
|
border: 1px solid #e3e8f0 !important;
|
||||||
|
border-radius: 20px !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
background: #ffffff !important;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
/* IE and Edge */
|
||||||
|
scrollbar-width: none;
|
||||||
|
/* Firefox */
|
||||||
|
|
||||||
|
.service-sidebar__profile,
|
||||||
|
.service-sidebar__divider {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-nav__item {
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사이드바가 세로로 배치되면 가로 여백이 없으므로 100vw로 보정
|
// 사이드바가 세로로 배치되면 가로 여백이 없으므로 100vw로 보정
|
||||||
@@ -1008,7 +1037,7 @@ $o2leg-err-fg: #a23b3b;
|
|||||||
// -- Section 2: 사전 준비 ----------------------------------------------------
|
// -- Section 2: 사전 준비 ----------------------------------------------------
|
||||||
&__prereq-grid {
|
&__prereq-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1079,18 +1108,28 @@ $o2leg-err-fg: #a23b3b;
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
|
|
||||||
|
// 연속 배치된 grid 사이 간격을 내부 gap(24px)과 동일하게 유지
|
||||||
|
// (언어별 코드 블럭이 두 grid 에 나뉘어 있어 간격이 달라 보이는 문제 방지)
|
||||||
|
+ .oauth2-2legged__step-grid {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__endpoint-box {
|
&__endpoint-box {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr auto;
|
grid-template-columns: 1fr;
|
||||||
align-items: center;
|
gap: 8px;
|
||||||
gap: 16px;
|
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border: 1px solid $o2leg-border;
|
border: 1px solid $o2leg-border;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|
||||||
|
.oauth2-2legged__method,
|
||||||
|
.oauth2-2legged__endpoint-content-type {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__method {
|
&__method {
|
||||||
@@ -1244,6 +1283,7 @@ $o2leg-err-fg: #a23b3b;
|
|||||||
background: $o2leg-text-dark;
|
background: $o2leg-text-dark;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
margin-top: 24px;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: "";
|
content: "";
|
||||||
|
|||||||
@@ -13,31 +13,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</th:block>
|
</th:block>
|
||||||
<th:block layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
<section class="api-market-container">
|
<section class="container api-market-container">
|
||||||
|
|
||||||
<!-- Sidebar Navigation -->
|
<!-- Sidebar Navigation -->
|
||||||
<aside class="api-market-sidebar" id="apiSidebar">
|
<aside class="service-sidebar" id="apiSidebar">
|
||||||
|
<nav class="service-nav">
|
||||||
<div class="api-sidebar-header">
|
|
||||||
<img th:src="@{/img/api_sidebar.png}" alt="API">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="api-sidebar-nav">
|
|
||||||
<!-- All APIs -->
|
<!-- All APIs -->
|
||||||
<div class="menu-section">
|
<a class="service-nav__item"
|
||||||
<a class="menu-title" th:classappend="${selected == '-1'} ? 'active'" th:href="@{/apis}">
|
th:classappend="${selected == '-1' || #request.getParameter('selected') == '-1'} ? 'service-nav__item--active' : ''"
|
||||||
전체
|
th:href="@{/apis}">
|
||||||
</a>
|
전체
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
<!-- Service Categories -->
|
<!-- Service Categories -->
|
||||||
<div class="menu-section" th:each="service : ${services}">
|
<a class="service-nav__item" th:each="service : ${services}"
|
||||||
<a class="menu-title"
|
th:classappend="${(#request.getParameter('selected') != null ? #request.getParameter('selected') : (selected != null ? selected : apiSpecInfo.service)) == service.id} ? 'service-nav__item--active' : ''"
|
||||||
th:classappend="${selected == service.id} ? 'active'"
|
th:href="@{/apis(groupIds=${service.id})}">
|
||||||
th:href="@{/apis(groupIds=${service.id})}">
|
[[${service.groupName}]]
|
||||||
[[${service.groupName}]]
|
</a>
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -93,7 +86,9 @@
|
|||||||
<div class="api-simple-description" th:if="${apiSpecInfo.apiSimpleDescription != null}">
|
<div class="api-simple-description" th:if="${apiSpecInfo.apiSimpleDescription != null}">
|
||||||
<p th:text="${apiSpecInfo.apiSimpleDescription}">Simple API description</p>
|
<p th:text="${apiSpecInfo.apiSimpleDescription}">Simple API description</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-content" th:utext="${apiSpecInfo.description}">
|
<!--/* [editor-content] 관리자포탈 Summernote 콘텐츠 스타일 정렬(editor-content.css).
|
||||||
|
과거 스타일(자체 detail-content SCSS)로 회귀하려면 editor-content 클래스만 제거 */-->
|
||||||
|
<div class="detail-content editor-content" th:utext="${apiSpecInfo.description}">
|
||||||
Detailed API description
|
Detailed API description
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,7 +101,8 @@
|
|||||||
<div class="org-section-header org-section-header--agreement">
|
<div class="org-section-header org-section-header--agreement">
|
||||||
<h3>Request Specification</h3>
|
<h3>Request Specification</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-content" th:utext="${apiSpecInfo.apiRequestSpec}">
|
<!--/* [editor-content] 회귀 시 editor-content 클래스 제거 */-->
|
||||||
|
<div class="detail-content editor-content" th:utext="${apiSpecInfo.apiRequestSpec}">
|
||||||
Request spec
|
Request spec
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,7 +122,8 @@
|
|||||||
<div class="org-section-header org-section-header--agreement">
|
<div class="org-section-header org-section-header--agreement">
|
||||||
<h3>Response Specification</h3>
|
<h3>Response Specification</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-content" th:utext="${apiSpecInfo.apiResponseSpec}">
|
<!--/* [editor-content] 회귀 시 editor-content 클래스 제거 */-->
|
||||||
|
<div class="detail-content editor-content" th:utext="${apiSpecInfo.apiResponseSpec}">
|
||||||
Response spec
|
Response spec
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -301,7 +298,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const swaggerUrl = `/djb/testbed/apis/${apiId}/swagger.json`;
|
// UI 전용 spec: 서버 주소가 responseType(sample/mock/gw) 설정에 따라 치환됨.
|
||||||
|
// (swagger.json 은 외부 다운로드용으로 항상 GW 주소 고정)
|
||||||
|
const swaggerUrl = `/djb/testbed/apis/${apiId}/swagger-ui.json`;
|
||||||
|
|
||||||
// Load Swagger UI scripts dynamically
|
// Load Swagger UI scripts dynamically
|
||||||
const loadScript = (src) => {
|
const loadScript = (src) => {
|
||||||
|
|||||||
@@ -8,8 +8,7 @@
|
|||||||
<section class="service-hero">
|
<section class="service-hero">
|
||||||
<div class="service-hero__inner">
|
<div class="service-hero__inner">
|
||||||
<div class="service-hero__icon-wrapper">
|
<div class="service-hero__icon-wrapper">
|
||||||
<img th:src="@{/img/keyimage/api_img.svg}" alt="OPEN API 3D 아이콘"
|
<img th:src="@{/img/keyimage/api_img.png}" alt="OPEN API 3D 아이콘" class="service-keyImg" />
|
||||||
style="width: 100%; height: 100%; object-fit: contain;" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="service-hero__content">
|
<div class="service-hero__content">
|
||||||
<div class="service-hero__badge">
|
<div class="service-hero__badge">
|
||||||
@@ -129,8 +128,11 @@
|
|||||||
apiCards.forEach(function (card) {
|
apiCards.forEach(function (card) {
|
||||||
// Click handler
|
// Click handler
|
||||||
card.addEventListener('click', function () {
|
card.addEventListener('click', function () {
|
||||||
const href = this.getAttribute('data-href');
|
let href = this.getAttribute('data-href');
|
||||||
if (href) {
|
if (href) {
|
||||||
|
const selectedGroup = groupIdsInput ? groupIdsInput.value : '';
|
||||||
|
const val = selectedGroup ? selectedGroup : '-1';
|
||||||
|
href += (href.includes('?') ? '&' : '?') + 'selected=' + encodeURIComponent(val);
|
||||||
window.location.href = href;
|
window.location.href = href;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -139,8 +141,11 @@
|
|||||||
card.addEventListener('keypress', function (e) {
|
card.addEventListener('keypress', function (e) {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const href = this.getAttribute('data-href');
|
let href = this.getAttribute('data-href');
|
||||||
if (href) {
|
if (href) {
|
||||||
|
const selectedGroup = groupIdsInput ? groupIdsInput.value : '';
|
||||||
|
const val = selectedGroup ? selectedGroup : '-1';
|
||||||
|
href += (href.includes('?') ? '&' : '?') + 'selected=' + encodeURIComponent(val);
|
||||||
window.location.href = href;
|
window.location.href = href;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,116 +1,157 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<section layout:fragment="title">
|
<section layout:fragment="title">
|
||||||
<div class="page-title-banner">
|
<div class="page-title-banner">
|
||||||
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
|
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
|
||||||
<h1>공지사항</h1>
|
<h1>공지사항</h1>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<th:block layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
<div class="signup-guide-v2">
|
<div class="signup-guide-v2">
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<section class="service-hero">
|
<section class="service-hero">
|
||||||
<div class="service-hero__inner">
|
<div class="service-hero__inner">
|
||||||
<div class="service-hero__icon-wrapper">
|
<div class="service-hero__icon-wrapper">
|
||||||
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘"
|
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘"
|
||||||
style="width: 100%; height: 100%; object-fit: contain;" />
|
style="width: 100%; height: 100%; object-fit: contain;" />
|
||||||
</div>
|
|
||||||
<div class="service-hero__content">
|
|
||||||
<div class="service-hero__badge">
|
|
||||||
<span class="service-hero__badge-dot"></span>
|
|
||||||
<span class="service-hero__badge-text">고객지원</span>
|
|
||||||
</div>
|
</div>
|
||||||
<h1 class="service-hero__title">공지사항</h1>
|
<div class="service-hero__content">
|
||||||
<p class="service-hero__desc">DJBank 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해 주시기 바랍니다.</p>
|
<div class="service-hero__badge">
|
||||||
</div>
|
<span class="service-hero__badge-dot"></span>
|
||||||
</div>
|
<span class="service-hero__badge-text">고객지원</span>
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<div class="service-main">
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('notice')}"></th:block>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<section class="service-content">
|
|
||||||
<div class="notice-detail-container" style="padding: 0; min-height: auto; margin: 0; background: transparent; box-shadow: none;">
|
|
||||||
|
|
||||||
<!-- Notice Header: Title and Date -->
|
|
||||||
<div class="notice-detail-header">
|
|
||||||
<p class="notice-detail-date" th:text="${#temporals.format(portalNotice.createdDate, 'yyyy.MM.dd')}">2026.06.24</p>
|
|
||||||
<div class="notice-detail-title-wrapper">
|
|
||||||
<span class="notice-type-badge notice-type-badge--incident" th:if="${portalNotice.noticeType == '3'}">장애</span>
|
|
||||||
<span class="notice-type-badge notice-type-badge--maintenance" th:if="${portalNotice.noticeType == '2'}">점검</span>
|
|
||||||
<h2 class="notice-detail-title" th:text="${portalNotice.noticeSubject}">공지사항 디테일 입니다</h2>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h1 class="service-hero__title">공지사항</h1>
|
||||||
|
<p class="service-hero__desc">DJBank 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해
|
||||||
|
주시기 바랍니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 장애/점검 정보 영역 -->
|
|
||||||
<div class="notice-detail-incident-info" th:if="${portalNotice.incidentOrMaintenance}">
|
|
||||||
<table class="detail-table">
|
|
||||||
<colgroup>
|
|
||||||
<col style="width: 160px;"><col><col style="width: 160px;"><col>
|
|
||||||
</colgroup>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<th>시작</th>
|
|
||||||
<td th:text="${portalNotice.startedAt != null ? #temporals.format(portalNotice.startedAt, 'yyyy-MM-dd HH:mm') : '-'}">-</td>
|
|
||||||
<th>끝</th>
|
|
||||||
<td th:text="${portalNotice.endAt != null ? #temporals.format(portalNotice.endAt, 'yyyy-MM-dd HH:mm') : '진행중'}">-</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>상태</th>
|
|
||||||
<td th:text="${portalNotice.state != null ? portalNotice.state : '-'}">-</td>
|
|
||||||
<th>영향 API</th>
|
|
||||||
<td>
|
|
||||||
<th:block th:if="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}">
|
|
||||||
<span th:each="api, iterStat : ${portalNotice.affectedApis}">
|
|
||||||
<strong th:text="${api.apiId}">API_ID</strong><span th:if="${api.apiName != null and !api.apiName.isEmpty()}" th:text="| - ${api.apiName}|"></span><th:block th:if="${!iterStat.last}">, </th:block>
|
|
||||||
</span>
|
|
||||||
</th:block>
|
|
||||||
<th:block th:unless="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}">-</th:block>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Attachment Section -->
|
|
||||||
<div class="notice-detail-attachment" th:if="${!#strings.isEmpty(portalNotice.fileId)}">
|
|
||||||
<div class="attachment-list" th:with="fileInfo=${@fileService.findById(portalNotice.fileId)}">
|
|
||||||
<div class="attachment-item" th:each="fileDetail, status : ${fileInfo.getFileDetails()}">
|
|
||||||
<a th:href="'javascript:fn_downloadFile(\'' + ${fileDetail.fileId} + '\',\''+ ${fileDetail.fileSn} +'\')'" class="notice-attachment-link">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M18 15.75C18 17.4833 17.3917 18.9583 16.175 20.175C14.9583 21.3917 13.4833 22 11.75 22C10.0167 22 8.54167 21.3917 7.325 20.175C6.10833 18.9583 5.5 17.4833 5.5 15.75V6.5C5.5 5.25 5.9375 4.1875 6.8125 3.3125C7.6875 2.4375 8.75 2 10 2C11.25 2 12.3125 2.4375 13.1875 3.3125C14.0625 4.1875 14.5 5.25 14.5 6.5V15.25C14.5 16.0167 14.2333 16.6667 13.7 17.2C13.1667 17.7333 12.5167 18 11.75 18C10.9833 18 10.3333 17.7333 9.8 17.2C9.26667 16.6667 9 16.0167 9 15.25V6H11V15.25C11 15.4667 11.0708 15.6458 11.2125 15.7875C11.3542 15.9292 11.5333 16 11.75 16C11.9667 16 12.1458 15.9292 12.2875 15.7875C12.4292 15.6458 12.5 15.4667 12.5 15.25V6.5C12.4833 5.8 12.2375 5.20833 11.7625 4.725C11.2875 4.24167 10.7 4 10 4C9.3 4 8.70833 4.24167 8.225 4.725C7.74167 5.20833 7.5 5.8 7.5 6.5V15.75C7.48333 16.9333 7.89167 17.9375 8.725 18.7625C9.55833 19.5875 10.5667 20 11.75 20C12.9167 20 13.9083 19.5875 14.725 18.7625C15.5417 17.9375 15.9667 16.9333 16 15.75V6H18V15.75Z" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
<span class="attachment-label">첨부파일</span>
|
|
||||||
<span class="attachment-filename">[[${fileDetail.originalFileName}]].[[${fileDetail.fileExtension}]]</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content Section -->
|
|
||||||
<div class="notice-detail-content">
|
|
||||||
<div id="noticeDetail" class="notice-content-body editor-content" th:utext="${portalNotice.noticeDetail}">
|
|
||||||
공지사항 내용이 여기에 표시됩니다.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
<div class="notice-detail-actions">
|
|
||||||
<button type="button" class="btn-notice-list" th:onclick="|location.href='@{/portalnotice}'|">목록</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="container service-main">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('notice')}"></th:block>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<section class="service-content">
|
||||||
|
<div class="notice-detail-container"
|
||||||
|
style="padding: 0; min-height: auto; margin: 0; background: transparent; box-shadow: none;">
|
||||||
|
|
||||||
|
<!-- Notice Header: Title and Date -->
|
||||||
|
<div class="notice-detail-header">
|
||||||
|
<p class="notice-detail-date" th:text="${#temporals.format(portalNotice.createdDate, 'yyyy.MM.dd')}">
|
||||||
|
2026.06.24</p>
|
||||||
|
<div class="notice-detail-title-wrapper">
|
||||||
|
<span class="notice-type-badge lg notice-type-badge--incident"
|
||||||
|
th:if="${portalNotice.noticeType == '3'}">장애</span>
|
||||||
|
<span class="notice-type-badge lg notice-type-badge--maintenance"
|
||||||
|
th:if="${portalNotice.noticeType == '2'}">점검</span>
|
||||||
|
<h2 class="notice-detail-title" th:text="${portalNotice.noticeSubject}">공지사항 디테일 입니다</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 장애/점검 정보 영역 -->
|
||||||
|
<div class="notice-detail-incident-info" th:if="${portalNotice.incidentOrMaintenance}">
|
||||||
|
<table class="detail-table">
|
||||||
|
<colgroup>
|
||||||
|
<col style="width: 160px;">
|
||||||
|
<col>
|
||||||
|
<col style="width: 160px;">
|
||||||
|
<col>
|
||||||
|
</colgroup>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>시작</th>
|
||||||
|
<td
|
||||||
|
th:text="${portalNotice.startedAt != null ? #temporals.format(portalNotice.startedAt, 'yyyy-MM-dd HH:mm') : '-'}">
|
||||||
|
-</td>
|
||||||
|
<th>끝</th>
|
||||||
|
<td
|
||||||
|
th:text="${portalNotice.endAt != null ? #temporals.format(portalNotice.endAt, 'yyyy-MM-dd HH:mm') : '진행중'}">
|
||||||
|
-</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>상태</th>
|
||||||
|
<td colspan="3" th:text="${portalNotice.state != null ? portalNotice.state : '-'}">-</td>
|
||||||
|
</tr>
|
||||||
|
<tr th:if="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}">
|
||||||
|
<th>영향 API</th>
|
||||||
|
<td colspan="3">
|
||||||
|
<div class="affected-apis-wrapper">
|
||||||
|
<!-- First 3 APIs (Always visible) -->
|
||||||
|
<div class="affected-apis-list">
|
||||||
|
<span class="affected-api-badge" th:each="api, iterStat : ${portalNotice.affectedApis}"
|
||||||
|
th:if="${iterStat.index < 3}">
|
||||||
|
<strong th:text="${api.apiId}">API_ID</strong><span
|
||||||
|
th:if="${api.apiName != null and !api.apiName.isEmpty()}"
|
||||||
|
th:text="| - ${api.apiName}|"></span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Rest of APIs (Hidden by default) -->
|
||||||
|
<th:block th:if="${portalNotice.affectedApis.size() > 3}">
|
||||||
|
<span class="affected-api-badge extra-api"
|
||||||
|
th:each="api, iterStat : ${portalNotice.affectedApis}" th:if="${iterStat.index >= 3}"
|
||||||
|
style="display: none;">
|
||||||
|
<strong th:text="${api.apiId}">API_ID</strong><span
|
||||||
|
th:if="${api.apiName != null and !api.apiName.isEmpty()}"
|
||||||
|
th:text="| - ${api.apiName}|"></span>
|
||||||
|
</span>
|
||||||
|
</th:block>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toggle Button -->
|
||||||
|
<button type="button" class="btn-affected-toggle"
|
||||||
|
th:if="${portalNotice.affectedApis.size() > 3}" onclick="toggleAffectedApis(this)">
|
||||||
|
리스트 전체보기 <span class="arrow">▼</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attachment Section -->
|
||||||
|
<div class="notice-detail-attachment" th:if="${!#strings.isEmpty(portalNotice.fileId)}">
|
||||||
|
<div class="attachment-list" th:with="fileInfo=${@fileService.findById(portalNotice.fileId)}">
|
||||||
|
<div class="attachment-item" th:each="fileDetail, status : ${fileInfo.getFileDetails()}">
|
||||||
|
<a th:href="'javascript:fn_downloadFile(\'' + ${fileDetail.fileId} + '\',\''+ ${fileDetail.fileSn} +'\')'"
|
||||||
|
class="notice-attachment-link">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M18 15.75C18 17.4833 17.3917 18.9583 16.175 20.175C14.9583 21.3917 13.4833 22 11.75 22C10.0167 22 8.54167 21.3917 7.325 20.175C6.10833 18.9583 5.5 17.4833 5.5 15.75V6.5C5.5 5.25 5.9375 4.1875 6.8125 3.3125C7.6875 2.4375 8.75 2 10 2C11.25 2 12.3125 2.4375 13.1875 3.3125C14.0625 4.1875 14.5 5.25 14.5 6.5V15.25C14.5 16.0167 14.2333 16.6667 13.7 17.2C13.1667 17.7333 12.5167 18 11.75 18C10.9833 18 10.3333 17.7333 9.8 17.2C9.26667 16.6667 9 16.0167 9 15.25V6H11V15.25C11 15.4667 11.0708 15.6458 11.2125 15.7875C11.3542 15.9292 11.5333 16 11.75 16C11.9667 16 12.1458 15.9292 12.2875 15.7875C12.4292 15.6458 12.5 15.4667 12.5 15.25V6.5C12.4833 5.8 12.2375 5.20833 11.7625 4.725C11.2875 4.24167 10.7 4 10 4C9.3 4 8.70833 4.24167 8.225 4.725C7.74167 5.20833 7.5 5.8 7.5 6.5V15.75C7.48333 16.9333 7.89167 17.9375 8.725 18.7625C9.55833 19.5875 10.5667 20 11.75 20C12.9167 20 13.9083 19.5875 14.725 18.7625C15.5417 17.9375 15.9667 16.9333 16 15.75V6H18V15.75Z"
|
||||||
|
fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
<span class="attachment-label">첨부파일</span>
|
||||||
|
<span
|
||||||
|
class="attachment-filename">[[${fileDetail.originalFileName}]].[[${fileDetail.fileExtension}]]</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Section -->
|
||||||
|
<div class="notice-detail-content">
|
||||||
|
<div id="noticeDetail" class="notice-content-body editor-content" th:utext="${portalNotice.noticeDetail}">
|
||||||
|
공지사항 내용이 여기에 표시됩니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="notice-detail-actions">
|
||||||
|
<button type="button" class="btn-action-primary btn-notice-list"
|
||||||
|
th:onclick="|location.href='@{/portalnotice}'|">목록</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</th:block>
|
||||||
</th:block>
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
@@ -120,13 +161,31 @@
|
|||||||
window.open('[[@{/file/download}]]' + "?fileId=" + fileId + "&fileSn=" + fileSn);
|
window.open('[[@{/file/download}]]' + "?fileId=" + fileId + "&fileSn=" + fileSn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleAffectedApis(btn) {
|
||||||
|
const wrapper = btn.closest('.affected-apis-wrapper');
|
||||||
|
const extraApis = wrapper.querySelectorAll('.extra-api');
|
||||||
|
const isExpanded = btn.classList.contains('active');
|
||||||
|
|
||||||
|
extraApis.forEach(el => {
|
||||||
|
el.style.display = isExpanded ? 'none' : 'inline-flex';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
btn.innerHTML = '리스트 전체보기 <span class="arrow">▼</span>';
|
||||||
|
} else {
|
||||||
|
btn.classList.add('active');
|
||||||
|
btn.innerHTML = '리스트 접기 <span class="arrow">▲</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function decodeHTMLEntities(text) {
|
function decodeHTMLEntities(text) {
|
||||||
var textArea = document.createElement('textarea');
|
var textArea = document.createElement('textarea');
|
||||||
textArea.innerHTML = text;
|
textArea.innerHTML = text;
|
||||||
return textArea.value;
|
return textArea.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
var element = document.getElementById('noticeDetail');
|
var element = document.getElementById('noticeDetail');
|
||||||
if (element) {
|
if (element) {
|
||||||
element.innerHTML = decodeHTMLEntities(element.innerHTML);
|
element.innerHTML = decodeHTMLEntities(element.innerHTML);
|
||||||
@@ -135,4 +194,4 @@
|
|||||||
</script>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -16,8 +16,7 @@
|
|||||||
<section class="service-hero">
|
<section class="service-hero">
|
||||||
<div class="service-hero__inner">
|
<div class="service-hero__inner">
|
||||||
<div class="service-hero__icon-wrapper">
|
<div class="service-hero__icon-wrapper">
|
||||||
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘"
|
<img th:src="@{/img/keyimage/notice_img.png}" alt="공지사항 아이콘" class="service-keyImg" />
|
||||||
style="width: 100%; height: 100%; object-fit: contain;" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="service-hero__content">
|
<div class="service-hero__content">
|
||||||
<div class="service-hero__badge">
|
<div class="service-hero__badge">
|
||||||
@@ -32,7 +31,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="service-main">
|
<div class="container service-main">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('notice')}"></th:block>
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('notice')}"></th:block>
|
||||||
|
|
||||||
@@ -86,8 +85,10 @@
|
|||||||
<span class="notice-number"
|
<span class="notice-number"
|
||||||
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
|
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
|
||||||
<span class="notice-type-badge notice-type-badge--fix" th:if="${notice.fixYn == 'Y'}">고정</span>
|
<span class="notice-type-badge notice-type-badge--fix" th:if="${notice.fixYn == 'Y'}">고정</span>
|
||||||
<span class="notice-type-badge notice-type-badge--incident" th:if="${notice.noticeType == '3'}">장애</span>
|
<span class="notice-type-badge notice-type-badge--incident"
|
||||||
<span class="notice-type-badge notice-type-badge--maintenance" th:if="${notice.noticeType == '2'}">점검</span>
|
th:if="${notice.noticeType == '3'}">장애</span>
|
||||||
|
<span class="notice-type-badge notice-type-badge--maintenance"
|
||||||
|
th:if="${notice.noticeType == '2'}">점검</span>
|
||||||
<span th:text="${notice.noticeSubject}">공지사항 제목</span>
|
<span th:text="${notice.noticeSubject}">공지사항 제목</span>
|
||||||
<span class="file-icon" th:if="${notice.fileId != null and !notice.fileId.isEmpty()}">
|
<span class="file-icon" th:if="${notice.fileId != null and !notice.fileId.isEmpty()}">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|||||||
@@ -115,6 +115,24 @@
|
|||||||
form.checkId.checked = ((form.id.value = getCookie('saveid')) !== null);
|
form.checkId.checked = ((form.id.value = getCookie('saveid')) !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF 토큰 재발급 안전망: keepalive ping이 끊긴 경우(절전·네트워크 단절 등)
|
||||||
|
// 세션이 만료됐어도 제출 직전 새 토큰을 받아 403(만료) 대신 정상 로그인되게 한다.
|
||||||
|
// 재발급 실패 시에는 기존 토큰으로 그대로 제출한다.
|
||||||
|
function refreshCsrfAndThen(form, next) {
|
||||||
|
$.ajax({
|
||||||
|
url: /*[[@{/api/session/csrf}]]*/ '/api/session/csrf',
|
||||||
|
type: 'GET',
|
||||||
|
success: function (data) {
|
||||||
|
if (data && data.token) {
|
||||||
|
if (form['_csrf']) { form['_csrf'].value = data.token; }
|
||||||
|
var meta = document.querySelector('meta[name="_csrf"]');
|
||||||
|
if (meta) { meta.content = data.token; }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
complete: function () { next(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 로그인 전 중복 접속 확인 → 중복 시 기존 세션 강제 로그아웃 여부 질의
|
// 로그인 전 중복 접속 확인 → 중복 시 기존 세션 강제 로그아웃 여부 질의
|
||||||
function checkDuplicateAndLogin(form) {
|
function checkDuplicateAndLogin(form) {
|
||||||
var loginId = form.id.value;
|
var loginId = form.id.value;
|
||||||
@@ -157,26 +175,8 @@
|
|||||||
|
|
||||||
$(function () {
|
$(function () {
|
||||||
|
|
||||||
// CSRF 토큰은 세션 기반 → 세션 만료(server.servlet.session.timeout=10m) 전에
|
// 세션 만료 대응은 head의 익명 keepalive ping(/api/session/ping)이 담당하고,
|
||||||
// 메인 페이지로 이동시켜 stale 토큰 제출(403) 방지. 이동은 서버 요청이라 세션 idle도 리셋됨.
|
// 제출 직전 refreshCsrfAndThen()이 CSRF 토큰 재발급 안전망 역할을 한다.
|
||||||
// 단, 입력 중에는 이탈 금지 → idle 타이머(활동 시 리셋, 입력값/포커스 있으면 보류).
|
|
||||||
// 만료 시간은 하드코딩하지 않고 서버 세션 타임아웃(초)을 렌더 시점에 읽어 파생 → yml 변경 시 자동 반영.
|
|
||||||
var SESSION_TIMEOUT_SEC = /*[[${#request.session.maxInactiveInterval}]]*/ 600;
|
|
||||||
var LOGIN_IDLE_LIMIT_MS = Math.max(60, SESSION_TIMEOUT_SEC - 120) * 1000; // 세션 만료 2분 전
|
|
||||||
var loginIdleTimer;
|
|
||||||
function scheduleIdleRedirect() {
|
|
||||||
clearTimeout(loginIdleTimer);
|
|
||||||
loginIdleTimer = setTimeout(function () {
|
|
||||||
var idEl = document.getElementById('id');
|
|
||||||
var pwEl = document.getElementById('password');
|
|
||||||
var busy = document.activeElement === idEl || document.activeElement === pwEl
|
|
||||||
|| (idEl && idEl.value) || (pwEl && pwEl.value);
|
|
||||||
if (busy) { scheduleIdleRedirect(); return; } // 입력 중/입력값 있음 → 이탈 보류
|
|
||||||
window.location.href = /*[[@{/}]]*/ '/';
|
|
||||||
}, LOGIN_IDLE_LIMIT_MS);
|
|
||||||
}
|
|
||||||
$('#id, #password, #checkId').on('input keydown focus click', scheduleIdleRedirect);
|
|
||||||
scheduleIdleRedirect();
|
|
||||||
|
|
||||||
var successMsg = [[${success}]];
|
var successMsg = [[${success}]];
|
||||||
console.log("Success message:", successMsg);
|
console.log("Success message:", successMsg);
|
||||||
@@ -225,7 +225,9 @@
|
|||||||
$('#loginLoading').removeClass('active');
|
$('#loginLoading').removeClass('active');
|
||||||
customPopups.showAlert('[[#{login.passLengthShort}]]');
|
customPopups.showAlert('[[#{login.passLengthShort}]]');
|
||||||
} else {
|
} else {
|
||||||
checkDuplicateAndLogin(form);
|
refreshCsrfAndThen(form, function () {
|
||||||
|
checkDuplicateAndLogin(form);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
form.classList.add('was-validated');
|
form.classList.add('was-validated');
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -256,8 +256,8 @@
|
|||||||
<h3 class="app-card-title" th:text="${request.clientName}">앱 이름</h3>
|
<h3 class="app-card-title" th:text="${request.clientName}">앱 이름</h3>
|
||||||
<!-- Status Badge -->
|
<!-- Status Badge -->
|
||||||
<span class="app-card-badge status-pending"
|
<span class="app-card-badge status-pending"
|
||||||
th:text="${request.approval != null and request.approval.approvalStatus != null ? request.approval.approvalStatus.description : '승인대기'}">
|
th:text="${request.approval != null and request.approval.approvalStatus != null ? request.approval.approvalStatus.description : '승인정보 없음'}">
|
||||||
승인대기
|
승인정보 없음
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- App Description & Expected Completion Date -->
|
<!-- App Description & Expected Completion Date -->
|
||||||
|
|||||||
@@ -111,13 +111,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Call Back URL : OAuth2 3-legged 전용. 현재 미사용으로 주석 처리 (추후 OAuth2 도입 시 복원)
|
<!--/* Call Back URL : OAuth2 3-legged 전용. 현재 미사용으로 주석 처리 (추후 OAuth2 도입 시 복원)
|
||||||
<div class="s1-field">
|
<div class="s1-field">
|
||||||
<label class="s1-label">Call Back URL</label>
|
<label class="s1-label">Call Back URL</label>
|
||||||
<input type="text" id="callbackUrl" name="callbackUrl" th:field="*{callbackUrl}" class="s1-input"
|
<input type="text" id="callbackUrl" name="callbackUrl" th:field="*{callbackUrl}" class="s1-input"
|
||||||
placeholder="URL을 입력해 주세요.">
|
placeholder="URL을 입력해 주세요.">
|
||||||
</div>
|
</div>
|
||||||
-->
|
*/-->
|
||||||
|
|
||||||
<!-- 화이트 리스트 -->
|
<!-- 화이트 리스트 -->
|
||||||
<div class="s1-field">
|
<div class="s1-field">
|
||||||
|
|||||||
@@ -54,6 +54,9 @@
|
|||||||
대기중
|
대기중
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="dt-app-status-row" th:if="${appRequest.approval == null}">
|
||||||
|
<span class="dt-status-badge status-pending">승인정보 없음</span>
|
||||||
|
</div>
|
||||||
<h3 class="dt-app-name" th:text="${appRequest.clientName}">앱 이름</h3>
|
<h3 class="dt-app-name" th:text="${appRequest.clientName}">앱 이름</h3>
|
||||||
<!-- App Description -->
|
<!-- App Description -->
|
||||||
<p class="dt-app-desc"
|
<p class="dt-app-desc"
|
||||||
@@ -200,12 +203,19 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 내부 결재 진행중: 취소 불가 안내 -->
|
||||||
|
<p class="dt-cancel-notice" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"
|
||||||
|
th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and
|
||||||
|
appRequest.approval.approvalStatus.toString() == 'PROCESSING'}">
|
||||||
|
내부 결재가 진행 중이라 신청을 취소할 수 없습니다. 취소가 필요한 경우 관리자에게 문의해 주세요.
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- Bottom Navigation Actions -->
|
<!-- Bottom Navigation Actions -->
|
||||||
<div class="dt-actions">
|
<div class="dt-actions">
|
||||||
<!-- Cancel Request Button (danger red) -->
|
<!-- Cancel Request Button (danger red) -->
|
||||||
<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:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and
|
th:if="${appRequest.approval == null or (appRequest.approval.approvalStatus != null and
|
||||||
(appRequest.approval.approvalStatus.toString() == 'PENDING' or appRequest.approval.approvalStatus.toString() == 'REQUESTED')}"
|
(appRequest.approval.approvalStatus.toString() == 'PENDING' or appRequest.approval.approvalStatus.toString() == 'REQUESTED'))}"
|
||||||
th:data-request-id="${appRequest.id}" onclick="cancelRequestById(this)">
|
th:data-request-id="${appRequest.id}" onclick="cancelRequestById(this)">
|
||||||
신청 취소
|
신청 취소
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -51,16 +51,6 @@
|
|||||||
<!-- Credential Info Section Card -->
|
<!-- Credential Info Section Card -->
|
||||||
<div class="dt-info-container">
|
<div class="dt-info-container">
|
||||||
|
|
||||||
<!-- 인증 방식 -->
|
|
||||||
<div class="dt-row">
|
|
||||||
<div class="dt-col">
|
|
||||||
<label class="dt-label">인증 방식</label>
|
|
||||||
<div class="dt-input-group">
|
|
||||||
<div class="dt-input-box" th:text="${authType}">OAuth2</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Client ID -->
|
<!-- Client ID -->
|
||||||
<div class="dt-row">
|
<div class="dt-row">
|
||||||
<div class="dt-col">
|
<div class="dt-col">
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
<!-- Step 1 -->
|
<!-- Step 1 -->
|
||||||
<div class="signup-step">
|
<div class="signup-step">
|
||||||
<div class="signup-step__icon-box one">
|
<div class="signup-step__icon-box one">
|
||||||
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path
|
||||||
d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z"
|
d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z"
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
d="M19 14C19 12.8954 19.8954 12 21 12C22.1046 12 23 12.8954 23 14C23 15.1046 22.1046 16 21 16C19.8954 16 19 15.1046 19 14Z"
|
d="M19 14C19 12.8954 19.8954 12 21 12C22.1046 12 23 12.8954 23 14C23 15.1046 22.1046 16 21 16C19.8954 16 19 15.1046 19 14Z"
|
||||||
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
|
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
|
||||||
stroke-linejoin="round" />
|
stroke-linejoin="round" />
|
||||||
</svg> -->
|
</svg> */-->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
class="bi bi-person-add" viewBox="0 0 16 16">
|
class="bi bi-person-add" viewBox="0 0 16 16">
|
||||||
<path
|
<path
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
<!-- Step 2 -->
|
<!-- Step 2 -->
|
||||||
<div class="signup-step">
|
<div class="signup-step">
|
||||||
<div class="signup-step__icon-box two">
|
<div class="signup-step__icon-box two">
|
||||||
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path
|
||||||
d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z"
|
d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z"
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
stroke-linejoin="round" />
|
stroke-linejoin="round" />
|
||||||
<path d="M19 16L21 18L25 14" stroke="#0049B4" stroke-width="1.6"
|
<path d="M19 16L21 18L25 14" stroke="#0049B4" stroke-width="1.6"
|
||||||
stroke-linecap="round" stroke-linejoin="round" />
|
stroke-linecap="round" stroke-linejoin="round" />
|
||||||
</svg> -->
|
</svg> */-->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
class="bi bi-journal-check" viewBox="0 0 16 16">
|
class="bi bi-journal-check" viewBox="0 0 16 16">
|
||||||
<path fill-rule="evenodd"
|
<path fill-rule="evenodd"
|
||||||
@@ -115,14 +115,14 @@
|
|||||||
<!-- Step 3 -->
|
<!-- Step 3 -->
|
||||||
<div class="signup-step">
|
<div class="signup-step">
|
||||||
<div class="signup-step__icon-box three">
|
<div class="signup-step__icon-box three">
|
||||||
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"
|
<rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"
|
||||||
stroke-linecap="round" stroke-linejoin="round" />
|
stroke-linecap="round" stroke-linejoin="round" />
|
||||||
<path d="M12 12H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
<path d="M12 12H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
||||||
<path d="M12 16H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
<path d="M12 16H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
||||||
<path d="M12 20H16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
<path d="M12 20H16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
||||||
</svg> -->
|
</svg> */-->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
class="bi bi-code" viewBox="0 0 16 16">
|
class="bi bi-code" viewBox="0 0 16 16">
|
||||||
<path
|
<path
|
||||||
@@ -138,13 +138,13 @@
|
|||||||
<!-- Step 4 -->
|
<!-- Step 4 -->
|
||||||
<div class="signup-step">
|
<div class="signup-step">
|
||||||
<div class="signup-step__icon-box four">
|
<div class="signup-step__icon-box four">
|
||||||
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M21 8H24V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
|
<path d="M21 8H24V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
|
||||||
stroke-linejoin="round" />
|
stroke-linejoin="round" />
|
||||||
<path d="M8 8H11V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
|
<path d="M8 8H11V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
|
||||||
stroke-linejoin="round" />
|
stroke-linejoin="round" />
|
||||||
</svg> -->
|
</svg> */-->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
class="bi bi-display" viewBox="0 0 16 16">
|
class="bi bi-display" viewBox="0 0 16 16">
|
||||||
<path
|
<path
|
||||||
@@ -160,14 +160,14 @@
|
|||||||
<!-- Step 5 -->
|
<!-- Step 5 -->
|
||||||
<div class="signup-step">
|
<div class="signup-step">
|
||||||
<div class="signup-step__icon-box five">
|
<div class="signup-step__icon-box five">
|
||||||
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"-->
|
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"*/-->
|
||||||
<!-- xmlns="http://www.w3.org/2000/svg">-->
|
<!-- xmlns="http://www.w3.org/2000/svg">-->
|
||||||
<!-- <rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"-->
|
<!--/* <rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"*/-->
|
||||||
<!-- stroke-linecap="round" stroke-linejoin="round" />-->
|
<!-- stroke-linecap="round" stroke-linejoin="round" />-->
|
||||||
<!-- <path d="M12 11H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />-->
|
<!--/* <path d="M12 11H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />*/-->
|
||||||
<!-- <path d="M12 15L14.5 17.5L20 12" stroke="#0049B4" stroke-width="1.6"-->
|
<!--/* <path d="M12 15L14.5 17.5L20 12" stroke="#0049B4" stroke-width="1.6"*/-->
|
||||||
<!-- stroke-linecap="round" stroke-linejoin="round" />-->
|
<!-- stroke-linecap="round" stroke-linejoin="round" />-->
|
||||||
<!-- </svg>-->
|
<!--/* </svg>*/-->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
class="bi bi-ui-checks-grid" viewBox="0 0 16 16">
|
class="bi bi-ui-checks-grid" viewBox="0 0 16 16">
|
||||||
<path
|
<path
|
||||||
@@ -183,7 +183,7 @@
|
|||||||
<!-- Step 6 -->
|
<!-- Step 6 -->
|
||||||
<div class="signup-step">
|
<div class="signup-step">
|
||||||
<div class="signup-step__icon-box six">
|
<div class="signup-step__icon-box six">
|
||||||
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect x="7" y="9" width="18" height="16" rx="2" stroke="#0049B4" stroke-width="1.6"
|
<rect x="7" y="9" width="18" height="16" rx="2" stroke="#0049B4" stroke-width="1.6"
|
||||||
stroke-linecap="round" stroke-linejoin="round" />
|
stroke-linecap="round" stroke-linejoin="round" />
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
<path d="M20 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
<path d="M20 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
||||||
<path d="M12 17H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
<path d="M12 17H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
||||||
<path d="M12 21H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
<path d="M12 21H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
||||||
</svg> -->
|
</svg> */-->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
class="bi bi-airplane" viewBox="0 0 16 16">
|
class="bi bi-airplane" viewBox="0 0 16 16">
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
<span class="oauth2-2legged__prereq-num">3</span>
|
<span class="oauth2-2legged__prereq-num">3</span>
|
||||||
<div class="oauth2-2legged__prereq-body">
|
<div class="oauth2-2legged__prereq-body">
|
||||||
<h3 class="oauth2-2legged__prereq-title">Scope 확인</h3>
|
<h3 class="oauth2-2legged__prereq-title">Scope 확인</h3>
|
||||||
<p>호출하려는 API에 필요한 scope</p>
|
<p>scope 는 고정값 "api" 사용</p>
|
||||||
<p>권한이 부여됐는지 확인합니다.</p>
|
<p>권한이 부여됐는지 확인합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
<text x="560" y="102" text-anchor="middle" font-size="12" font-weight="700"
|
<text x="560" y="102" text-anchor="middle" font-size="12" font-weight="700"
|
||||||
fill="#0049b4">① POST /dj/oauth/token</text>
|
fill="#0049b4">① POST /dj/oauth/token</text>
|
||||||
<text x="560" y="118" text-anchor="middle" font-size="11" font-weight="500"
|
<text x="560" y="118" text-anchor="middle" font-size="11" font-weight="500"
|
||||||
fill="#64748B">grant_type=client_credentials, client_id, client_secret, scope</text>
|
fill="#64748B">grant_type=client_credentials, client_id, client_secret, scope=api</text>
|
||||||
<line x1="240" y1="128" x2="880" y2="128" stroke="#0049b4" stroke-width="2"
|
<line x1="240" y1="128" x2="880" y2="128" stroke="#0049b4" stroke-width="2"
|
||||||
marker-end="url(#o2leg-arrow-primary)" />
|
marker-end="url(#o2leg-arrow-primary)" />
|
||||||
|
|
||||||
@@ -145,11 +145,11 @@
|
|||||||
<line x1="880" y1="170" x2="240" y2="170" stroke="#64748B" stroke-width="2"
|
<line x1="880" y1="170" x2="240" y2="170" stroke="#64748B" stroke-width="2"
|
||||||
marker-end="url(#o2leg-arrow-gray)" />
|
marker-end="url(#o2leg-arrow-gray)" />
|
||||||
<text x="560" y="188" text-anchor="middle" font-size="11" font-weight="500"
|
<text x="560" y="188" text-anchor="middle" font-size="11" font-weight="500"
|
||||||
fill="#64748B">{ access_token, token_type:"bearer", expires_in:86400, scope, jti
|
fill="#64748B">{ access_token, token_type:"bearer", expires_in:86400, scope:"api", jti
|
||||||
}</text>
|
}</text>
|
||||||
|
|
||||||
<text x="560" y="216" text-anchor="middle" font-size="12" font-weight="700"
|
<text x="560" y="216" text-anchor="middle" font-size="12" font-weight="700"
|
||||||
fill="#0049b4">③ GET /api/v1/... · Authorization: Bearer <access_token></text>
|
fill="#0049b4">③ GET /api/v1/... · X-AUTH-TOKEN: Bearer <access_token></text>
|
||||||
<line x1="240" y1="226" x2="880" y2="226" stroke="#0049b4" stroke-width="2"
|
<line x1="240" y1="226" x2="880" y2="226" stroke="#0049b4" stroke-width="2"
|
||||||
marker-end="url(#o2leg-arrow-primary)" />
|
marker-end="url(#o2leg-arrow-primary)" />
|
||||||
|
|
||||||
@@ -210,9 +210,9 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><code>scope</code></td>
|
<td><code>scope</code></td>
|
||||||
<td><span
|
<td><span
|
||||||
class="oauth2-2legged__req-badge oauth2-2legged__req-badge--optional">선택</span>
|
class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">필수</span>
|
||||||
</td>
|
</td>
|
||||||
<td>호출 권한 범위 (공백 구분)</td>
|
<td>고정값 "api"</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -228,7 +228,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
|
|||||||
-d <span class="o2leg-c">'grant_type=client_credentials'</span> \
|
-d <span class="o2leg-c">'grant_type=client_credentials'</span> \
|
||||||
-d <span class="o2leg-c">'client_id=YOUR_CLIENT_ID'</span> \
|
-d <span class="o2leg-c">'client_id=YOUR_CLIENT_ID'</span> \
|
||||||
-d <span class="o2leg-c">'client_secret=YOUR_CLIENT_SECRET'</span> \
|
-d <span class="o2leg-c">'client_secret=YOUR_CLIENT_SECRET'</span> \
|
||||||
-d <span class="o2leg-c">'scope=read.accounts'</span>
|
-d <span class="o2leg-c">'scope=api'</span>
|
||||||
|
|
||||||
<span class="o2leg-g"># 응답: 200 OK + JSON</span></pre>
|
<span class="o2leg-g"># 응답: 200 OK + JSON</span></pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -248,7 +248,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
|
|||||||
<span class="o2leg-c">"access_token"</span>: <span class="o2leg-y">"eyJhbGciOiJSUzI1NiJ9..."</span>,
|
<span class="o2leg-c">"access_token"</span>: <span class="o2leg-y">"eyJhbGciOiJSUzI1NiJ9..."</span>,
|
||||||
<span class="o2leg-c">"token_type"</span>: <span class="o2leg-y">"bearer"</span>,
|
<span class="o2leg-c">"token_type"</span>: <span class="o2leg-y">"bearer"</span>,
|
||||||
<span class="o2leg-c">"expires_in"</span>: <span class="o2leg-p">86400</span>,
|
<span class="o2leg-c">"expires_in"</span>: <span class="o2leg-p">86400</span>,
|
||||||
<span class="o2leg-c">"scope"</span>: <span class="o2leg-y">"read.accounts"</span>,
|
<span class="o2leg-c">"scope"</span>: <span class="o2leg-y">"api"</span>,
|
||||||
<span class="o2leg-c">"jti"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-..."</span>
|
<span class="o2leg-c">"jti"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-..."</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
|
|||||||
<tr>
|
<tr>
|
||||||
<td><code class="oauth2-2legged__field-name">scope</code></td>
|
<td><code class="oauth2-2legged__field-name">scope</code></td>
|
||||||
<td>string</td>
|
<td>string</td>
|
||||||
<td>실제 부여된 권한 범위</td>
|
<td>부여된 권한 범위 (api)</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code class="oauth2-2legged__field-name">jti</code></td>
|
<td><code class="oauth2-2legged__field-name">jti</code></td>
|
||||||
@@ -301,13 +301,13 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
|
|||||||
<section class="oauth2-2legged__step" aria-labelledby="o2leg-step3-title">
|
<section class="oauth2-2legged__step" aria-labelledby="o2leg-step3-title">
|
||||||
<span class="oauth2-2legged__eyebrow">STEP 3</span>
|
<span class="oauth2-2legged__eyebrow">STEP 3</span>
|
||||||
<h2 class="oauth2-2legged__h2" id="o2leg-step3-title">발급 토큰으로 API 호출</h2>
|
<h2 class="oauth2-2legged__h2" id="o2leg-step3-title">발급 토큰으로 API 호출</h2>
|
||||||
<p class="oauth2-2legged__desc">Authorization 헤더에 Bearer 토큰을 실어 보호 자원 API 를 호출합니다.</p>
|
<p class="oauth2-2legged__desc">X-AUTH-TOKEN 헤더에 Bearer 토큰을 실어 보호 자원 API 를 호출합니다.</p>
|
||||||
|
|
||||||
<div class="oauth2-2legged__step-grid">
|
<div class="oauth2-2legged__step-grid">
|
||||||
<div class="oauth2-2legged__panel">
|
<div class="oauth2-2legged__panel">
|
||||||
<h3 class="oauth2-2legged__panel-title">필수 헤더</h3>
|
<h3 class="oauth2-2legged__panel-title">필수 헤더</h3>
|
||||||
<div class="oauth2-2legged__header-box">
|
<div class="oauth2-2legged__header-box">
|
||||||
<code class="oauth2-2legged__header-key">Authorization:</code>
|
<code class="oauth2-2legged__header-key">X-AUTH-TOKEN:</code>
|
||||||
<code class="oauth2-2legged__header-value">Bearer eyJhbGciOiJSUzI1NiJ9...</code>
|
<code class="oauth2-2legged__header-value">Bearer eyJhbGciOiJSUzI1NiJ9...</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -316,8 +316,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
|
|||||||
<li>동일 토큰은 expires_in(기본 86400초) 동안 재사용</li>
|
<li>동일 토큰은 expires_in(기본 86400초) 동안 재사용</li>
|
||||||
<li>만료 임박 시 재발급 후 교체 (예: TTL의 80% 시점)</li>
|
<li>만료 임박 시 재발급 후 교체 (예: TTL의 80% 시점)</li>
|
||||||
<li>매 호출마다 토큰을 새로 발급하지 마세요</li>
|
<li>매 호출마다 토큰을 새로 발급하지 마세요</li>
|
||||||
<li>권한이 다른 API 는 scope 별로 토큰을 분리 발급</li>
|
</ul>
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="oauth2-2legged__code-panel">
|
<div class="oauth2-2legged__code-panel">
|
||||||
@@ -325,7 +324,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
|
|||||||
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 보호 자원 API 호출</span>
|
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 보호 자원 API 호출</span>
|
||||||
curl -X <span class="o2leg-y">GET</span> \
|
curl -X <span class="o2leg-y">GET</span> \
|
||||||
<span class="o2leg-c">'https://openapi.djbank.co.kr/api/v1/accounts'</span> \
|
<span class="o2leg-c">'https://openapi.djbank.co.kr/api/v1/accounts'</span> \
|
||||||
-H <span class="o2leg-c">'Authorization: Bearer eyJhbGciOiJSUzI..'</span> \
|
-H <span class="o2leg-c">'X-AUTH-TOKEN: Bearer eyJhbGciOiJSUzI..'</span> \
|
||||||
-H <span class="o2leg-c">'Accept: application/json'</span>
|
-H <span class="o2leg-c">'Accept: application/json'</span>
|
||||||
|
|
||||||
<span class="o2leg-g"># 응답</span>
|
<span class="o2leg-g"># 응답</span>
|
||||||
|
|||||||
@@ -1,197 +1,224 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<th:block layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
<section class="oauth2-2legged">
|
<section class="oauth2-2legged">
|
||||||
|
|
||||||
<!-- Section 1: Hero -->
|
<!-- Section 1: Hero -->
|
||||||
<header class="oauth2-2legged__hero">
|
<header class="oauth2-2legged__hero">
|
||||||
<div class="oauth2-2legged__hero-inner">
|
<div class="oauth2-2legged__hero-inner">
|
||||||
<div class="oauth2-2legged__hero-body">
|
<div class="oauth2-2legged__hero-body">
|
||||||
<span class="oauth2-2legged__hero-eyebrow">
|
<span class="oauth2-2legged__hero-eyebrow">
|
||||||
<span class="oauth2-2legged__hero-eyebrow-dot"></span>
|
<span class="oauth2-2legged__hero-eyebrow-dot"></span>
|
||||||
개발 가이드 · Webhook · HMAC-SHA256
|
개발 가이드 · Webhook · HMAC-SHA256
|
||||||
</span>
|
</span>
|
||||||
<h1 class="oauth2-2legged__hero-title">웹훅 개발가이드</h1>
|
<h1 class="oauth2-2legged__hero-title">웹훅 개발가이드</h1>
|
||||||
<p class="oauth2-2legged__hero-lead">DJBank가 발송하는 Webhook 요청의 진위를 확인하기 위한</p>
|
<p class="oauth2-2legged__hero-lead">DJBank가 발송하는 Webhook 요청의 진위를 확인하기 위한 HMAC-SHA256 서명 검증 방법을
|
||||||
<p class="oauth2-2legged__hero-lead">HMAC-SHA256 서명 검증 방법을 단계별로 설명합니다.</p>
|
단계별로 설명합니다.</p>
|
||||||
|
|
||||||
<div class="oauth2-2legged__hero-chips">
|
<!-- <div class="oauth2-2legged__hero-chips">
|
||||||
<span class="oauth2-2legged__chip oauth2-2legged__chip--primary">HMAC-SHA256</span>
|
<span class="oauth2-2legged__chip oauth2-2legged__chip--primary">HMAC-SHA256</span>
|
||||||
<span class="oauth2-2legged__chip">X-Webhook-Signature</span>
|
<span class="oauth2-2legged__chip">X-Webhook-Signature</span>
|
||||||
<span class="oauth2-2legged__chip">Raw Body</span>
|
<span class="oauth2-2legged__chip">Raw Body</span>
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="oauth2-2legged__hero-illust" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 280 200" xmlns="http://www.w3.org/2000/svg" role="img"
|
||||||
|
aria-label="Signed Webhook Delivery">
|
||||||
|
<defs>
|
||||||
|
<marker id="whsig-hero-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8"
|
||||||
|
markerHeight="8" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="#0049b4" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="280" height="200" rx="20" fill="#FFFFFF" />
|
||||||
|
|
||||||
|
<rect x="24" y="92" width="86" height="52" rx="8" fill="#EDF9FE" stroke="#0049b4" />
|
||||||
|
<text x="67" y="114" text-anchor="middle" font-size="10" font-weight="700"
|
||||||
|
fill="#0049b4">DJBank</text>
|
||||||
|
<text x="67" y="128" text-anchor="middle" font-size="10" font-weight="700"
|
||||||
|
fill="#0049b4">Webhook</text>
|
||||||
|
|
||||||
|
<rect x="170" y="92" width="86" height="52" rx="8" fill="#EDF9FE" stroke="#0049b4" />
|
||||||
|
<text x="213" y="114" text-anchor="middle" font-size="10" font-weight="700"
|
||||||
|
fill="#0049b4">Your</text>
|
||||||
|
<text x="213" y="128" text-anchor="middle" font-size="10" font-weight="700"
|
||||||
|
fill="#0049b4">Endpoint</text>
|
||||||
|
|
||||||
|
<line x1="110" y1="118" x2="170" y2="118" stroke="#0049b4" stroke-width="2"
|
||||||
|
marker-end="url(#whsig-hero-arrow)" />
|
||||||
|
|
||||||
|
<g transform="translate(58,28)">
|
||||||
|
<rect width="164" height="34" rx="6" fill="#1A1A2E" />
|
||||||
|
<text x="12" y="16" font-family="'Fira Code', monospace" font-size="9"
|
||||||
|
fill="#00D4FF">X-Webhook-Signature:</text>
|
||||||
|
<text x="12" y="28" font-family="'Fira Code', monospace" font-size="9"
|
||||||
|
fill="#FFD93D">sha256=9f86d0..</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g transform="translate(133,104)">
|
||||||
|
<circle r="13" fill="#0049b4" />
|
||||||
|
<path d="M-5,-1 h10 v7 h-10 z M-3,-1 v-3 a3,3 0 0 1 6,0 v3" fill="none" stroke="#FFFFFF"
|
||||||
|
stroke-width="1.5" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="140" y="172" text-anchor="middle" font-size="11" font-weight="600"
|
||||||
|
fill="#64748B">Signed Webhook Delivery</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div class="oauth2-2legged__hero-illust" aria-hidden="true">
|
<div class="container service-main">
|
||||||
<svg viewBox="0 0 280 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Signed Webhook Delivery">
|
<!-- Sidebar -->
|
||||||
<defs>
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhookGuide')}"></th:block>
|
||||||
<marker id="whsig-hero-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
|
|
||||||
<path d="M0,0 L10,5 L0,10 Z" fill="#0049b4"/>
|
|
||||||
</marker>
|
|
||||||
</defs>
|
|
||||||
<rect x="0" y="0" width="280" height="200" rx="20" fill="#FFFFFF"/>
|
|
||||||
|
|
||||||
<rect x="24" y="92" width="86" height="52" rx="8" fill="#EDF9FE" stroke="#0049b4"/>
|
<div class="service-content">
|
||||||
<text x="67" y="114" text-anchor="middle" font-size="10" font-weight="700" fill="#0049b4">DJBank</text>
|
|
||||||
<text x="67" y="128" text-anchor="middle" font-size="10" font-weight="700" fill="#0049b4">Webhook</text>
|
|
||||||
|
|
||||||
<rect x="170" y="92" width="86" height="52" rx="8" fill="#EDF9FE" stroke="#0049b4"/>
|
<!-- Section 2: 개요 -->
|
||||||
<text x="213" y="114" text-anchor="middle" font-size="10" font-weight="700" fill="#0049b4">Your</text>
|
<section class="oauth2-2legged__prereq" aria-labelledby="whsig-prereq-title">
|
||||||
<text x="213" y="128" text-anchor="middle" font-size="10" font-weight="700" fill="#0049b4">Endpoint</text>
|
<span class="oauth2-2legged__eyebrow">OVERVIEW</span>
|
||||||
|
<h2 class="oauth2-2legged__h2" id="whsig-prereq-title">서명 검증이 필요한 이유</h2>
|
||||||
|
|
||||||
<line x1="110" y1="118" x2="170" y2="118" stroke="#0049b4" stroke-width="2" marker-end="url(#whsig-hero-arrow)"/>
|
<div class="oauth2-2legged__prereq-grid">
|
||||||
|
<article class="oauth2-2legged__prereq-card">
|
||||||
|
<span class="oauth2-2legged__prereq-num">1</span>
|
||||||
|
<div class="oauth2-2legged__prereq-body">
|
||||||
|
<h3 class="oauth2-2legged__prereq-title">Secret 확보</h3>
|
||||||
|
<p>[마이페이지 > Webhook 관리]에서 발급된</p>
|
||||||
|
<p>Secret Key를 서버에 안전하게 보관.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<g transform="translate(58,28)">
|
<article class="oauth2-2legged__prereq-card">
|
||||||
<rect width="164" height="34" rx="6" fill="#1A1A2E"/>
|
<span class="oauth2-2legged__prereq-num">2</span>
|
||||||
<text x="12" y="16" font-family="'Fira Code', monospace" font-size="9" fill="#00D4FF">X-Webhook-Signature:</text>
|
<div class="oauth2-2legged__prereq-body">
|
||||||
<text x="12" y="28" font-family="'Fira Code', monospace" font-size="9" fill="#FFD93D">sha256=9f86d0..</text>
|
<h3 class="oauth2-2legged__prereq-title">원문(raw body) 보존</h3>
|
||||||
</g>
|
<p>수신 즉시 본문을 파싱/재직렬화하지 말고</p>
|
||||||
|
<p>바이트 원문 그대로 서명 계산에 사용.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<g transform="translate(133,104)">
|
<article class="oauth2-2legged__prereq-card">
|
||||||
<circle r="13" fill="#0049b4"/>
|
<span class="oauth2-2legged__prereq-num">3</span>
|
||||||
<path d="M-5,-1 h10 v7 h-10 z M-3,-1 v-3 a3,3 0 0 1 6,0 v3" fill="none" stroke="#FFFFFF" stroke-width="1.5"/>
|
<div class="oauth2-2legged__prereq-body">
|
||||||
</g>
|
<h3 class="oauth2-2legged__prereq-title">HMAC 재계산·비교</h3>
|
||||||
|
<p>동일 Secret으로 HMAC-SHA256을 재계산해</p>
|
||||||
|
<p>헤더 서명과 상수 시간으로 비교.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<text x="140" y="172" text-anchor="middle" font-size="11" font-weight="600" fill="#64748B">Signed Webhook Delivery</text>
|
<!-- Section 3: 전체 서명·검증 시퀀스 -->
|
||||||
</svg>
|
<section class="oauth2-2legged__sequence" aria-labelledby="whsig-seq-title">
|
||||||
</div>
|
<span class="oauth2-2legged__eyebrow">SEQUENCE</span>
|
||||||
</div>
|
<h2 class="oauth2-2legged__h2" id="whsig-seq-title">전체 서명·검증 시퀀스</h2>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="container service-main">
|
<div class="oauth2-2legged__sequence-diagram">
|
||||||
<!-- Sidebar -->
|
<svg viewBox="0 0 1120 300" xmlns="http://www.w3.org/2000/svg" role="img"
|
||||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhookGuide')}"></th:block>
|
aria-label="Webhook Signature Verification Sequence">
|
||||||
|
<defs>
|
||||||
|
<marker id="whsig-arrow-primary" viewBox="0 0 10 10" refX="9" refY="5"
|
||||||
|
markerWidth="8" markerHeight="8" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="#0049b4" />
|
||||||
|
</marker>
|
||||||
|
<marker id="whsig-arrow-gray" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8"
|
||||||
|
markerHeight="8" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="#64748B" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
<div class="service-content">
|
<g>
|
||||||
|
<rect x="120" y="24" width="240" height="48" rx="24" fill="#EDF9FE"
|
||||||
|
stroke="#0049b4" />
|
||||||
|
<text x="240" y="54" text-anchor="middle" font-size="14" font-weight="700"
|
||||||
|
fill="#0049b4">DJBank Webhook Sender</text>
|
||||||
|
<line x1="240" y1="72" x2="240" y2="272" stroke="#94A3B8" stroke-dasharray="4 4" />
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="760" y="24" width="240" height="48" rx="24" fill="#FFFFFF"
|
||||||
|
stroke="#0049b4" />
|
||||||
|
<text x="880" y="54" text-anchor="middle" font-size="14" font-weight="700"
|
||||||
|
fill="#0049b4">Your Endpoint</text>
|
||||||
|
<line x1="880" y1="72" x2="880" y2="272" stroke="#94A3B8" stroke-dasharray="4 4" />
|
||||||
|
</g>
|
||||||
|
|
||||||
<!-- Section 2: 개요 -->
|
<text x="240" y="104" text-anchor="middle" font-size="12" font-weight="700"
|
||||||
<section class="oauth2-2legged__prereq" aria-labelledby="whsig-prereq-title">
|
fill="#64748B">① 이벤트 발생 (점검·지연·장애)</text>
|
||||||
<span class="oauth2-2legged__eyebrow">OVERVIEW</span>
|
<text x="240" y="124" text-anchor="middle" font-size="12" font-weight="700"
|
||||||
<h2 class="oauth2-2legged__h2" id="whsig-prereq-title">서명 검증이 필요한 이유</h2>
|
fill="#64748B">② signature = HMAC-SHA256(secret, body)</text>
|
||||||
|
|
||||||
<div class="oauth2-2legged__prereq-grid">
|
<text x="560" y="156" text-anchor="middle" font-size="12" font-weight="700"
|
||||||
<article class="oauth2-2legged__prereq-card">
|
fill="#0049b4">③ POST body · X-Webhook-Signature: sha256=<hex></text>
|
||||||
<span class="oauth2-2legged__prereq-num">1</span>
|
<line x1="240" y1="166" x2="880" y2="166" stroke="#0049b4" stroke-width="2"
|
||||||
<div class="oauth2-2legged__prereq-body">
|
marker-end="url(#whsig-arrow-primary)" />
|
||||||
<h3 class="oauth2-2legged__prereq-title">Secret 확보</h3>
|
|
||||||
<p>[마이페이지 > Webhook 관리]에서 발급된</p>
|
|
||||||
<p>Secret Key를 서버에 안전하게 보관.</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="oauth2-2legged__prereq-card">
|
<text x="880" y="198" text-anchor="middle" font-size="12" font-weight="700"
|
||||||
<span class="oauth2-2legged__prereq-num">2</span>
|
fill="#64748B">④ 동일 secret으로 재계산</text>
|
||||||
<div class="oauth2-2legged__prereq-body">
|
<text x="880" y="218" text-anchor="middle" font-size="12" font-weight="700"
|
||||||
<h3 class="oauth2-2legged__prereq-title">원문(raw body) 보존</h3>
|
fill="#64748B">⑤ 상수 시간 비교 (일치 여부)</text>
|
||||||
<p>수신 즉시 본문을 파싱/재직렬화하지 말고</p>
|
|
||||||
<p>바이트 원문 그대로 서명 계산에 사용.</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="oauth2-2legged__prereq-card">
|
<text x="560" y="250" text-anchor="middle" font-size="12" font-weight="700"
|
||||||
<span class="oauth2-2legged__prereq-num">3</span>
|
fill="#64748B">⑥ 200 OK (검증 성공 시)</text>
|
||||||
<div class="oauth2-2legged__prereq-body">
|
<line x1="880" y1="260" x2="240" y2="260" stroke="#64748B" stroke-width="2"
|
||||||
<h3 class="oauth2-2legged__prereq-title">HMAC 재계산·비교</h3>
|
marker-end="url(#whsig-arrow-gray)" />
|
||||||
<p>동일 Secret으로 HMAC-SHA256을 재계산해</p>
|
</svg>
|
||||||
<p>헤더 서명과 상수 시간으로 비교.</p>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Section 3: 전체 서명·검증 시퀀스 -->
|
<!-- Section 4: STEP 1 — 수신 요청 형식 -->
|
||||||
<section class="oauth2-2legged__sequence" aria-labelledby="whsig-seq-title">
|
<section class="oauth2-2legged__step" aria-labelledby="whsig-step1-title">
|
||||||
<span class="oauth2-2legged__eyebrow">SEQUENCE</span>
|
<span class="oauth2-2legged__eyebrow">STEP 1</span>
|
||||||
<h2 class="oauth2-2legged__h2" id="whsig-seq-title">전체 서명·검증 시퀀스</h2>
|
<h2 class="oauth2-2legged__h2" id="whsig-step1-title">수신 요청 형식</h2>
|
||||||
|
<p class="oauth2-2legged__desc">DJBank는 등록한 수신 URL로 아래 형태의 POST 요청을 전송합니다.</p>
|
||||||
|
|
||||||
<div class="oauth2-2legged__sequence-diagram">
|
<div class="oauth2-2legged__endpoint-box">
|
||||||
<svg viewBox="0 0 1120 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Webhook Signature Verification Sequence">
|
<span class="oauth2-2legged__method">POST</span>
|
||||||
<defs>
|
<code class="oauth2-2legged__endpoint-path">https://your-service.example.com/webhook</code>
|
||||||
<marker id="whsig-arrow-primary" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
|
<span class="oauth2-2legged__endpoint-content-type">application/json</span>
|
||||||
<path d="M0,0 L10,5 L0,10 Z" fill="#0049b4"/>
|
</div>
|
||||||
</marker>
|
|
||||||
<marker id="whsig-arrow-gray" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
|
|
||||||
<path d="M0,0 L10,5 L0,10 Z" fill="#64748B"/>
|
|
||||||
</marker>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<g>
|
<div class="oauth2-2legged__step-grid">
|
||||||
<rect x="120" y="24" width="240" height="48" rx="24" fill="#EDF9FE" stroke="#0049b4"/>
|
<div class="oauth2-2legged__panel">
|
||||||
<text x="240" y="54" text-anchor="middle" font-size="14" font-weight="700" fill="#0049b4">DJBank Webhook Sender</text>
|
<h3 class="oauth2-2legged__panel-title">요청 헤더</h3>
|
||||||
<line x1="240" y1="72" x2="240" y2="272" stroke="#94A3B8" stroke-dasharray="4 4"/>
|
<table class="oauth2-2legged__table">
|
||||||
</g>
|
<thead>
|
||||||
<g>
|
<tr>
|
||||||
<rect x="760" y="24" width="240" height="48" rx="24" fill="#FFFFFF" stroke="#0049b4"/>
|
<th>HEADER</th>
|
||||||
<text x="880" y="54" text-anchor="middle" font-size="14" font-weight="700" fill="#0049b4">Your Endpoint</text>
|
<th>설명</th>
|
||||||
<line x1="880" y1="72" x2="880" y2="272" stroke="#94A3B8" stroke-dasharray="4 4"/>
|
</tr>
|
||||||
</g>
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>X-Webhook-Signature</code></td>
|
||||||
|
<td><code>sha256=<hex></code> — 본문 HMAC-SHA256 서명(소문자 hex)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>X-Webhook-Event</code></td>
|
||||||
|
<td>이벤트 코드 (예: CONTROL_START)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>X-Webhook-Timestamp</code></td>
|
||||||
|
<td>발송 시각 (epoch millis) — 재전송 방어용</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Content-Type</code></td>
|
||||||
|
<td>application/json</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="oauth2-2legged__warning">⚠ 서명 대상은 파싱 전 <strong>본문 원문(raw body)</strong> 입니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<text x="240" y="104" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">① 이벤트 발생 (점검·지연·장애)</text>
|
<div class="oauth2-2legged__code-panel">
|
||||||
<text x="240" y="124" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">② signature = HMAC-SHA256(secret, body)</text>
|
<span class="oauth2-2legged__code-tag">Request Body · JSON</span>
|
||||||
|
<pre class="oauth2-2legged__code-block">{
|
||||||
<text x="560" y="156" text-anchor="middle" font-size="12" font-weight="700" fill="#0049b4">③ POST body · X-Webhook-Signature: sha256=<hex></text>
|
|
||||||
<line x1="240" y1="166" x2="880" y2="166" stroke="#0049b4" stroke-width="2" marker-end="url(#whsig-arrow-primary)"/>
|
|
||||||
|
|
||||||
<text x="880" y="198" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">④ 동일 secret으로 재계산</text>
|
|
||||||
<text x="880" y="218" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">⑤ 상수 시간 비교 (일치 여부)</text>
|
|
||||||
|
|
||||||
<text x="560" y="250" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">⑥ 200 OK (검증 성공 시)</text>
|
|
||||||
<line x1="880" y1="260" x2="240" y2="260" stroke="#64748B" stroke-width="2" marker-end="url(#whsig-arrow-gray)"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Section 4: STEP 1 — 수신 요청 형식 -->
|
|
||||||
<section class="oauth2-2legged__step" aria-labelledby="whsig-step1-title">
|
|
||||||
<span class="oauth2-2legged__eyebrow">STEP 1</span>
|
|
||||||
<h2 class="oauth2-2legged__h2" id="whsig-step1-title">수신 요청 형식</h2>
|
|
||||||
<p class="oauth2-2legged__desc">DJBank는 등록한 수신 URL로 아래 형태의 POST 요청을 전송합니다.</p>
|
|
||||||
|
|
||||||
<div class="oauth2-2legged__endpoint-box">
|
|
||||||
<span class="oauth2-2legged__method">POST</span>
|
|
||||||
<code class="oauth2-2legged__endpoint-path">https://your-service.example.com/webhook</code>
|
|
||||||
<span class="oauth2-2legged__endpoint-content-type">application/json</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="oauth2-2legged__step-grid">
|
|
||||||
<div class="oauth2-2legged__panel">
|
|
||||||
<h3 class="oauth2-2legged__panel-title">요청 헤더</h3>
|
|
||||||
<table class="oauth2-2legged__table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>HEADER</th>
|
|
||||||
<th>설명</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>X-Webhook-Signature</code></td>
|
|
||||||
<td><code>sha256=<hex></code> — 본문 HMAC-SHA256 서명(소문자 hex)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>X-Webhook-Event</code></td>
|
|
||||||
<td>이벤트 코드 (예: CONTROL_START)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>X-Webhook-Timestamp</code></td>
|
|
||||||
<td>발송 시각 (epoch millis) — 재전송 방어용</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Content-Type</code></td>
|
|
||||||
<td>application/json</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p class="oauth2-2legged__warning">⚠ 서명 대상은 파싱 전 <strong>본문 원문(raw body)</strong> 입니다.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="oauth2-2legged__code-panel">
|
|
||||||
<span class="oauth2-2legged__code-tag">Request Body · JSON</span>
|
|
||||||
<pre class="oauth2-2legged__code-block">{
|
|
||||||
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CONTROL_START"</span>,
|
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CONTROL_START"</span>,
|
||||||
<span class="o2leg-c">"eventId"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-a567-0e02b2c3d479"</span>,
|
<span class="o2leg-c">"eventId"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-a567-0e02b2c3d479"</span>,
|
||||||
<span class="o2leg-c">"timestamp"</span>: <span class="o2leg-p">1723600000000</span>,
|
<span class="o2leg-c">"timestamp"</span>: <span class="o2leg-p">1723600000000</span>,
|
||||||
@@ -199,20 +226,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<span class="o2leg-g"># eventId: 발송 건 고유 ID · data: 영향 API 목록</span></pre>
|
<span class="o2leg-g"># eventId: 발송 건 고유 ID · data: 영향 API 목록</span></pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Section 5: STEP 2 — 서명 검증 알고리즘 -->
|
<!-- Section 5: STEP 2 — 서명 검증 알고리즘 -->
|
||||||
<section class="oauth2-2legged__step" aria-labelledby="whsig-step2-title">
|
<section class="oauth2-2legged__step" aria-labelledby="whsig-step2-title">
|
||||||
<span class="oauth2-2legged__eyebrow">STEP 2</span>
|
<span class="oauth2-2legged__eyebrow">STEP 2</span>
|
||||||
<h2 class="oauth2-2legged__h2" id="whsig-step2-title">서명 검증 알고리즘</h2>
|
<h2 class="oauth2-2legged__h2" id="whsig-step2-title">서명 검증 알고리즘</h2>
|
||||||
<p class="oauth2-2legged__desc">수신한 본문 원문과 발급된 Secret으로 서명을 재계산해 헤더 값과 비교합니다.</p>
|
<p class="oauth2-2legged__desc">수신한 본문 원문과 발급된 Secret으로 서명을 재계산해 헤더 값과 비교합니다.</p>
|
||||||
|
|
||||||
<div class="oauth2-2legged__step-grid">
|
<div class="oauth2-2legged__step-grid">
|
||||||
<div class="oauth2-2legged__code-panel">
|
<div class="oauth2-2legged__code-panel">
|
||||||
<span class="oauth2-2legged__code-tag">Verification Steps</span>
|
<span class="oauth2-2legged__code-tag">Verification Steps</span>
|
||||||
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 1) 헤더에서 서명 추출</span>
|
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 1) 헤더에서 서명 추출</span>
|
||||||
received = header[<span class="o2leg-c">"X-Webhook-Signature"</span>] <span class="o2leg-g"># "sha256=...."</span>
|
received = header[<span class="o2leg-c">"X-Webhook-Signature"</span>] <span class="o2leg-g"># "sha256=...."</span>
|
||||||
|
|
||||||
<span class="o2leg-cm"># 2) 본문 원문으로 HMAC-SHA256 재계산</span>
|
<span class="o2leg-cm"># 2) 본문 원문으로 HMAC-SHA256 재계산</span>
|
||||||
@@ -221,36 +248,36 @@ expected = <span class="o2leg-y">"sha256="</span> + toHexLower(digest)
|
|||||||
|
|
||||||
<span class="o2leg-cm"># 3) 상수 시간 비교</span>
|
<span class="o2leg-cm"># 3) 상수 시간 비교</span>
|
||||||
valid = constantTimeEquals(received, expected)</pre>
|
valid = constantTimeEquals(received, expected)</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="oauth2-2legged__panel">
|
<div class="oauth2-2legged__panel">
|
||||||
<h3 class="oauth2-2legged__panel-title">검증 규칙</h3>
|
<h3 class="oauth2-2legged__panel-title">검증 규칙</h3>
|
||||||
<ul class="oauth2-2legged__tips">
|
<ul class="oauth2-2legged__tips">
|
||||||
<li>알고리즘: <code>HmacSHA256</code>, 키: Secret(UTF-8 bytes)</li>
|
<li>알고리즘: <code>HmacSHA256</code>, 키: Secret(UTF-8 bytes)</li>
|
||||||
<li>메시지: 수신 <strong>본문 원문</strong>(UTF-8 bytes)</li>
|
<li>메시지: 수신 <strong>본문 원문</strong>(UTF-8 bytes)</li>
|
||||||
<li>출력: <strong>소문자 hex</strong>, 헤더는 <code>sha256=</code> 접두 포함</li>
|
<li>출력: <strong>소문자 hex</strong>, 헤더는 <code>sha256=</code> 접두 포함</li>
|
||||||
<li>비교: 타이밍 공격 방지를 위해 <strong>상수 시간</strong> 비교</li>
|
<li>비교: 타이밍 공격 방지를 위해 <strong>상수 시간</strong> 비교</li>
|
||||||
<li>불일치 시 요청을 폐기하고 2xx 이외로 응답</li>
|
<li>불일치 시 요청을 폐기하고 2xx 이외로 응답</li>
|
||||||
</ul>
|
</ul>
|
||||||
<h4 class="oauth2-2legged__panel-subtitle">재전송(replay) 방어</h4>
|
<h4 class="oauth2-2legged__panel-subtitle">재전송(replay) 방어</h4>
|
||||||
<ul class="oauth2-2legged__tips">
|
<ul class="oauth2-2legged__tips">
|
||||||
<li><code>X-Webhook-Timestamp</code>가 허용 오차(예: 5분) 밖이면 거부</li>
|
<li><code>X-Webhook-Timestamp</code>가 허용 오차(예: 5분) 밖이면 거부</li>
|
||||||
<li>동일 <code>eventId</code> 중복 수신은 멱등 처리</li>
|
<li>동일 <code>eventId</code> 중복 수신은 멱등 처리</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Section 6: STEP 3 — 언어별 예제 -->
|
<!-- Section 6: STEP 3 — 언어별 예제 -->
|
||||||
<section class="oauth2-2legged__step" aria-labelledby="whsig-step3-title">
|
<section class="oauth2-2legged__step" aria-labelledby="whsig-step3-title">
|
||||||
<span class="oauth2-2legged__eyebrow">STEP 3</span>
|
<span class="oauth2-2legged__eyebrow">STEP 3</span>
|
||||||
<h2 class="oauth2-2legged__h2" id="whsig-step3-title">언어별 서명 검증 예제</h2>
|
<h2 class="oauth2-2legged__h2" id="whsig-step3-title">언어별 서명 검증 예제</h2>
|
||||||
<p class="oauth2-2legged__desc">프레임워크에서 반드시 <strong>원문 바디</strong>에 접근할 수 있어야 합니다.</p>
|
<p class="oauth2-2legged__desc">프레임워크에서 반드시 <strong>원문 바디</strong>에 접근할 수 있어야 합니다.</p>
|
||||||
|
|
||||||
<div class="oauth2-2legged__step-grid">
|
<div class="oauth2-2legged__step-grid">
|
||||||
<div class="oauth2-2legged__code-panel">
|
<div class="oauth2-2legged__code-panel">
|
||||||
<span class="oauth2-2legged__code-tag">Node.js (Express)</span>
|
<span class="oauth2-2legged__code-tag">Node.js (Express)</span>
|
||||||
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">const</span> crypto = <span class="o2leg-y">require</span>(<span class="o2leg-c">'crypto'</span>);
|
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">const</span> crypto = <span class="o2leg-y">require</span>(<span class="o2leg-c">'crypto'</span>);
|
||||||
|
|
||||||
<span class="o2leg-cm">// rawBody: express.raw() 등으로 확보한 원문 Buffer</span>
|
<span class="o2leg-cm">// rawBody: express.raw() 등으로 확보한 원문 Buffer</span>
|
||||||
<span class="o2leg-p">function</span> <span class="o2leg-y">verify</span>(rawBody, header, secret) {
|
<span class="o2leg-p">function</span> <span class="o2leg-y">verify</span>(rawBody, header, secret) {
|
||||||
@@ -263,11 +290,11 @@ valid = constantTimeEquals(received, expected)</pre>
|
|||||||
<span class="o2leg-p">return</span> a.length === b.length &&
|
<span class="o2leg-p">return</span> a.length === b.length &&
|
||||||
crypto.<span class="o2leg-y">timingSafeEqual</span>(a, b);
|
crypto.<span class="o2leg-y">timingSafeEqual</span>(a, b);
|
||||||
}</pre>
|
}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="oauth2-2legged__code-panel">
|
<div class="oauth2-2legged__code-panel">
|
||||||
<span class="oauth2-2legged__code-tag">Python (Flask)</span>
|
<span class="oauth2-2legged__code-tag">Python (Flask)</span>
|
||||||
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">import</span> hmac, hashlib
|
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">import</span> hmac, hashlib
|
||||||
|
|
||||||
<span class="o2leg-cm"># raw_body: request.get_data() 로 확보한 bytes</span>
|
<span class="o2leg-cm"># raw_body: request.get_data() 로 확보한 bytes</span>
|
||||||
<span class="o2leg-p">def</span> <span class="o2leg-y">verify</span>(raw_body, header, secret):
|
<span class="o2leg-p">def</span> <span class="o2leg-y">verify</span>(raw_body, header, secret):
|
||||||
@@ -278,13 +305,13 @@ valid = constantTimeEquals(received, expected)</pre>
|
|||||||
).<span class="o2leg-y">hexdigest</span>()
|
).<span class="o2leg-y">hexdigest</span>()
|
||||||
expected = <span class="o2leg-c">'sha256='</span> + digest
|
expected = <span class="o2leg-c">'sha256='</span> + digest
|
||||||
<span class="o2leg-p">return</span> hmac.<span class="o2leg-y">compare_digest</span>(expected, header)</pre>
|
<span class="o2leg-p">return</span> hmac.<span class="o2leg-y">compare_digest</span>(expected, header)</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="oauth2-2legged__step-grid">
|
<div class="oauth2-2legged__step-grid">
|
||||||
<div class="oauth2-2legged__code-panel">
|
<div class="oauth2-2legged__code-panel">
|
||||||
<span class="oauth2-2legged__code-tag">Java</span>
|
<span class="oauth2-2legged__code-tag">Java</span>
|
||||||
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">import</span> javax.crypto.Mac;
|
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">import</span> javax.crypto.Mac;
|
||||||
<span class="o2leg-p">import</span> javax.crypto.spec.SecretKeySpec;
|
<span class="o2leg-p">import</span> javax.crypto.spec.SecretKeySpec;
|
||||||
<span class="o2leg-p">import</span> java.nio.charset.StandardCharsets;
|
<span class="o2leg-p">import</span> java.nio.charset.StandardCharsets;
|
||||||
<span class="o2leg-p">import</span> java.security.MessageDigest;
|
<span class="o2leg-p">import</span> java.security.MessageDigest;
|
||||||
@@ -301,84 +328,107 @@ valid = constantTimeEquals(received, expected)</pre>
|
|||||||
expected.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8),
|
expected.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8),
|
||||||
header.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8));
|
header.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8));
|
||||||
}</pre>
|
}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="oauth2-2legged__panel">
|
<div class="oauth2-2legged__panel">
|
||||||
<h3 class="oauth2-2legged__panel-title">이벤트 코드 (X-Webhook-Event)</h3>
|
<h3 class="oauth2-2legged__panel-title">이벤트 코드 (X-Webhook-Event)</h3>
|
||||||
<table class="oauth2-2legged__table">
|
<table class="oauth2-2legged__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>CODE</th>
|
<th>CODE</th>
|
||||||
<th>의미</th>
|
<th>의미</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td><code>CONTROL_START</code></td><td>점검 시작</td></tr>
|
<tr>
|
||||||
<tr><td><code>CONTROL_END</code></td><td>점검 종료</td></tr>
|
<td><code>CONTROL_START</code></td>
|
||||||
<tr><td><code>DELAY_START</code></td><td>지연 시작</td></tr>
|
<td>점검 시작</td>
|
||||||
<tr><td><code>DELAY_END</code></td><td>지연 종료</td></tr>
|
</tr>
|
||||||
<tr><td><code>ERROR_START</code></td><td>장애 시작</td></tr>
|
<tr>
|
||||||
<tr><td><code>ERROR_END</code></td><td>장애 종료</td></tr>
|
<td><code>CONTROL_END</code></td>
|
||||||
</tbody>
|
<td>점검 종료</td>
|
||||||
</table>
|
</tr>
|
||||||
</div>
|
<tr>
|
||||||
</div>
|
<td><code>DELAY_START</code></td>
|
||||||
</section>
|
<td>지연 시작</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>DELAY_END</code></td>
|
||||||
|
<td>지연 종료</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ERROR_START</code></td>
|
||||||
|
<td>장애 시작</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ERROR_END</code></td>
|
||||||
|
<td>장애 종료</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Section 6.5: STEP 4 — 응답 반환 규칙 -->
|
<!-- Section 6.5: STEP 4 — 응답 반환 규칙 -->
|
||||||
<section class="oauth2-2legged__step" aria-labelledby="whsig-step4-title">
|
<section class="oauth2-2legged__step" aria-labelledby="whsig-step4-title">
|
||||||
<span class="oauth2-2legged__eyebrow">STEP 4</span>
|
<span class="oauth2-2legged__eyebrow">STEP 4</span>
|
||||||
<h2 class="oauth2-2legged__h2" id="whsig-step4-title">응답(리턴) 반환 규칙</h2>
|
<h2 class="oauth2-2legged__h2" id="whsig-step4-title">응답(리턴) 반환 규칙</h2>
|
||||||
<p class="oauth2-2legged__desc">수신 서버가 반환하는 HTTP 상태 코드에 따라 DJBank의 성공 판정과 재시도가 결정됩니다.</p>
|
<p class="oauth2-2legged__desc">수신 서버가 반환하는 HTTP 상태 코드에 따라 DJBank의 성공 판정과 재시도가 결정됩니다.</p>
|
||||||
|
|
||||||
<div class="oauth2-2legged__step-grid">
|
<div class="oauth2-2legged__step-grid">
|
||||||
<div class="oauth2-2legged__panel">
|
<div class="oauth2-2legged__panel">
|
||||||
<h3 class="oauth2-2legged__panel-title">상태 코드별 처리</h3>
|
<h3 class="oauth2-2legged__panel-title">상태 코드별 처리</h3>
|
||||||
<table class="oauth2-2legged__table">
|
<table class="oauth2-2legged__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>반환</th>
|
<th>반환</th>
|
||||||
<th>상황</th>
|
<th>상황</th>
|
||||||
<th>DJBank 처리</th>
|
<th>DJBank 처리</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">2xx</span></td>
|
<td><span
|
||||||
<td>검증 성공 + 정상 접수</td>
|
class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">2xx</span>
|
||||||
<td><strong>발송 성공</strong> 기록. 재시도 없음</td>
|
</td>
|
||||||
</tr>
|
<td>검증 성공 + 정상 접수</td>
|
||||||
<tr>
|
<td><strong>발송 성공</strong> 기록. 재시도 없음</td>
|
||||||
<td><code>400</code></td>
|
</tr>
|
||||||
<td>필수 헤더 누락</td>
|
<tr>
|
||||||
<td>실패 기록 + 재시도</td>
|
<td><code>400</code></td>
|
||||||
</tr>
|
<td>필수 헤더 누락</td>
|
||||||
<tr>
|
<td>실패 기록</td>
|
||||||
<td><code>401</code></td>
|
</tr>
|
||||||
<td>서명 불일치 / timestamp 만료</td>
|
<tr>
|
||||||
<td>실패 기록 + 재시도</td>
|
<td><code>401</code></td>
|
||||||
</tr>
|
<td>서명 불일치 / timestamp 만료</td>
|
||||||
<tr>
|
<td>실패 기록</td>
|
||||||
<td><code>5xx</code> · 타임아웃</td>
|
</tr>
|
||||||
<td>수신 서버 일시 장애</td>
|
<tr>
|
||||||
<td>실패 기록 + 재시도</td>
|
<td><code>5xx</code> · 타임아웃</td>
|
||||||
</tr>
|
<td>수신 서버 일시 장애</td>
|
||||||
</tbody>
|
<td>실패 기록 + 재시도</td>
|
||||||
</table>
|
</tr>
|
||||||
<p class="oauth2-2legged__warning">⚠ 2xx 이외 응답과 네트워크 오류는 <strong>일정 간격을 두고 재시도</strong>됩니다(기본 3회). 재시도로 인한 중복 수신은 <code>eventId</code> 멱등 처리로 방어하세요.</p>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="oauth2-2legged__warning">⚠ 5xx 응답·타임아웃과 네트워크 오류는 <strong>일정 간격을 두고
|
||||||
|
재시도</strong>됩니다(기본 3회). 재시도로 인한 중복 수신은 <code>eventId</code> 멱등 처리로 방어하세요.</p>
|
||||||
|
>>>>>>> 9981459691c836bfd33b6b81a8e6aa22ca12446a
|
||||||
|
|
||||||
<h4 class="oauth2-2legged__panel-subtitle">응답 가이드</h4>
|
<h4 class="oauth2-2legged__panel-subtitle">응답 가이드</h4>
|
||||||
<ul class="oauth2-2legged__tips">
|
<ul class="oauth2-2legged__tips">
|
||||||
<li>검증 통과 시 <strong>즉시 200 OK</strong> 반환 — 무거운 후속 처리는 비동기로 분리</li>
|
<li>검증 통과 시 <strong>즉시 200 OK</strong> 반환 — 무거운 후속 처리는 비동기로 분리</li>
|
||||||
<li>응답 본문 규격은 자유(발송 로그에 기록만 됨) — 간단한 JSON 권장</li>
|
<li>응답 본문 규격은 자유(발송 로그에 기록만 됨) — 간단한 JSON 권장</li>
|
||||||
<li>서명 검증 실패는 <code>401</code>, 필수 헤더 누락은 <code>400</code> 반환 권장</li>
|
<li>서명 검증 실패는 <code>401</code>, 필수 헤더 누락은 <code>400</code> 반환 권장</li>
|
||||||
<li>동일 <code>eventId</code> 재수신 시 재처리 없이 200 반환(멱등)</li>
|
<li>동일 <code>eventId</code> 재수신 시 재처리 없이 200 반환(멱등)</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="oauth2-2legged__code-panel">
|
<div class="oauth2-2legged__code-panel">
|
||||||
<span class="oauth2-2legged__code-tag oauth2-2legged__code-tag--ok">200 OK · JSON (권장)</span>
|
<span class="oauth2-2legged__code-tag oauth2-2legged__code-tag--ok">200 OK · JSON
|
||||||
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 정상 접수</span>
|
(권장)</span>
|
||||||
|
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 정상 접수</span>
|
||||||
HTTP/1.1 <span class="o2leg-g">200 OK</span>
|
HTTP/1.1 <span class="o2leg-g">200 OK</span>
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
@@ -400,81 +450,81 @@ HTTP/1.1 <span class="o2leg-g">400 Bad Request</span>
|
|||||||
<span class="o2leg-c">"result"</span>: <span class="o2leg-y">"ERROR"</span>,
|
<span class="o2leg-c">"result"</span>: <span class="o2leg-y">"ERROR"</span>,
|
||||||
<span class="o2leg-c">"message"</span>: <span class="o2leg-y">"필수 헤더가 누락되었습니다."</span>
|
<span class="o2leg-c">"message"</span>: <span class="o2leg-y">"필수 헤더가 누락되었습니다."</span>
|
||||||
}</pre>
|
}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 7: 검증 실패 대표 원인 -->
|
||||||
|
<section class="oauth2-2legged__errors" aria-labelledby="whsig-errors-title">
|
||||||
|
<span class="oauth2-2legged__eyebrow">TROUBLESHOOTING</span>
|
||||||
|
<h2 class="oauth2-2legged__h2" id="whsig-errors-title">서명 불일치 대표 원인</h2>
|
||||||
|
|
||||||
|
<div class="oauth2-2legged__panel">
|
||||||
|
<table class="oauth2-2legged__table oauth2-2legged__table--errors">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>원인</th>
|
||||||
|
<th>증상</th>
|
||||||
|
<th>해결 가이드</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>본문 재직렬화</td>
|
||||||
|
<td>JSON 파싱 후 다시 문자열화한 값으로 서명 계산</td>
|
||||||
|
<td>파싱 전 raw body(bytes)로 계산</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>hex 대소문자</td>
|
||||||
|
<td>대문자 hex로 비교해 불일치</td>
|
||||||
|
<td>소문자 hex 사용</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>접두어 처리</td>
|
||||||
|
<td><code>sha256=</code> 접두 포함/제외 불일치</td>
|
||||||
|
<td>양쪽 모두 접두 포함 후 비교</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>인코딩</td>
|
||||||
|
<td>Secret/본문을 UTF-8 외로 인코딩</td>
|
||||||
|
<td>키·메시지 모두 UTF-8 bytes</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Secret 불일치</td>
|
||||||
|
<td>재발급 후 이전 Secret 사용</td>
|
||||||
|
<td>최신 Secret으로 교체</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>재전송</td>
|
||||||
|
<td>동일 이벤트 중복 수신</td>
|
||||||
|
<td>timestamp 검사 + eventId 멱등 처리</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 8: CTA -->
|
||||||
|
<a class="oauth2-2legged__cta" th:href="@{/webhook}">
|
||||||
|
<div class="oauth2-2legged__cta-body">
|
||||||
|
<span class="oauth2-2legged__cta-eyebrow">MANAGE</span>
|
||||||
|
<h2 class="oauth2-2legged__cta-title">Webhook 관리로 가기</h2>
|
||||||
|
<p class="oauth2-2legged__cta-desc">수신 URL·Secret·구독 이벤트를 등록하고 관리하세요.</p>
|
||||||
|
<span class="oauth2-2legged__cta-button">Webhook 관리 →</span>
|
||||||
|
</div>
|
||||||
|
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--lg" aria-hidden="true"></span>
|
||||||
|
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--sm" aria-hidden="true"></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
</th:block>
|
||||||
<!-- Section 7: 검증 실패 대표 원인 -->
|
|
||||||
<section class="oauth2-2legged__errors" aria-labelledby="whsig-errors-title">
|
|
||||||
<span class="oauth2-2legged__eyebrow">TROUBLESHOOTING</span>
|
|
||||||
<h2 class="oauth2-2legged__h2" id="whsig-errors-title">서명 불일치 대표 원인</h2>
|
|
||||||
|
|
||||||
<div class="oauth2-2legged__panel">
|
|
||||||
<table class="oauth2-2legged__table oauth2-2legged__table--errors">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>원인</th>
|
|
||||||
<th>증상</th>
|
|
||||||
<th>해결 가이드</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>본문 재직렬화</td>
|
|
||||||
<td>JSON 파싱 후 다시 문자열화한 값으로 서명 계산</td>
|
|
||||||
<td>파싱 전 raw body(bytes)로 계산</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>hex 대소문자</td>
|
|
||||||
<td>대문자 hex로 비교해 불일치</td>
|
|
||||||
<td>소문자 hex 사용</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>접두어 처리</td>
|
|
||||||
<td><code>sha256=</code> 접두 포함/제외 불일치</td>
|
|
||||||
<td>양쪽 모두 접두 포함 후 비교</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>인코딩</td>
|
|
||||||
<td>Secret/본문을 UTF-8 외로 인코딩</td>
|
|
||||||
<td>키·메시지 모두 UTF-8 bytes</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Secret 불일치</td>
|
|
||||||
<td>재발급 후 이전 Secret 사용</td>
|
|
||||||
<td>최신 Secret으로 교체</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>재전송</td>
|
|
||||||
<td>동일 이벤트 중복 수신</td>
|
|
||||||
<td>timestamp 검사 + eventId 멱등 처리</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Section 8: CTA -->
|
|
||||||
<a class="oauth2-2legged__cta" th:href="@{/webhook}">
|
|
||||||
<div class="oauth2-2legged__cta-body">
|
|
||||||
<span class="oauth2-2legged__cta-eyebrow">MANAGE</span>
|
|
||||||
<h2 class="oauth2-2legged__cta-title">Webhook 관리로 가기</h2>
|
|
||||||
<p class="oauth2-2legged__cta-desc">수신 URL·Secret·구독 이벤트를 등록하고 관리하세요.</p>
|
|
||||||
<span class="oauth2-2legged__cta-button">Webhook 관리 →</span>
|
|
||||||
</div>
|
|
||||||
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--lg" aria-hidden="true"></span>
|
|
||||||
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--sm" aria-hidden="true"></span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</section>
|
|
||||||
</th:block>
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -75,6 +75,7 @@
|
|||||||
color: #212529;
|
color: #212529;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-buttons {
|
.error-buttons {
|
||||||
@@ -246,7 +247,7 @@
|
|||||||
<div class="error-container">
|
<div class="error-container">
|
||||||
<!-- Error Icon -->
|
<!-- Error Icon -->
|
||||||
<div class="error-icon">
|
<div class="error-icon">
|
||||||
<!-- 3D Speech Bubble Error Icon - Base64 encoded or use th:src for server image -->
|
<!--/* 3D Speech Bubble Error Icon - Base64 encoded or use th:src for server image */-->
|
||||||
<img th:src="@{/img/error_3d.png}" alt="에러">
|
<img th:src="@{/img/error_3d.png}" alt="에러">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
|
||||||
<body>
|
<body>
|
||||||
<!--
|
<!--/*
|
||||||
API 선택 공용 모듈 (figma s2 디자인: 카테고리 캐러셀 탭 + 카드그리드 + 플로팅 카트 + 선택목록 모달)
|
API 선택 공용 모듈 (figma s2 디자인: 카테고리 캐러셀 탭 + 카드그리드 + 플로팅 카트 + 선택목록 모달)
|
||||||
|
|
||||||
사용처: 앱(API Key) 신청/수정 step2, Webhook 신청/수정 step2
|
사용처: 앱(API Key) 신청/수정 step2, Webhook 신청/수정 step2
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
- 추가 hidden 필드는 호출 페이지에서 form="apiSelectorForm" 속성으로 주입(예: apikey 수정 clientId).
|
- 추가 hidden 필드는 호출 페이지에서 form="apiSelectorForm" 속성으로 주입(예: apikey 수정 clientId).
|
||||||
- API 목록: GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX.
|
- API 목록: GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX.
|
||||||
- 스타일: design s2-* (_apikey-register.scss step2 재작업분) 재사용.
|
- 스타일: design s2-* (_apikey-register.scss step2 재작업분) 재사용.
|
||||||
-->
|
*/-->
|
||||||
<th:block th:fragment="apiSelector(apiServices, selectedApis, formAction, saveAction)">
|
<th:block th:fragment="apiSelector(apiServices, selectedApis, formAction, saveAction)">
|
||||||
|
|
||||||
<!-- Category Carousel Tab Container -->
|
<!-- Category Carousel Tab Container -->
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
<p class="pop_text l_text2">신청사유</p>
|
<p class="pop_text l_text2">신청사유</p>
|
||||||
<div class="pop_textbox">
|
<div class="pop_textbox">
|
||||||
<textarea name="reason" rows="5" cols="30" class="common_textareaType_1" placeholder="신청사유를 입력해 주세요."></textarea>
|
<textarea name="reason" rows="5" cols="30" class="common_textareaType_1" placeholder="신청사유를 입력해 주세요."></textarea>
|
||||||
<!-- <span>158/1,000 byte</span> -->
|
<!--/* <span>158/1,000 byte</span> */-->
|
||||||
</div>
|
</div>
|
||||||
<div class="pop_btnbox">
|
<div class="pop_btnbox">
|
||||||
<button class="popup_button_gray btn_cancel">취소</button>
|
<button class="popup_button_gray btn_cancel">취소</button>
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
|
||||||
|
|
||||||
<aside layout:fragment="apiAside" class="lnb">
|
|
||||||
<nav th:with="services=${@apiServiceService.searchApiGroupsForLnb()}">
|
|
||||||
<ul class="lnb_list_type">
|
|
||||||
<li>
|
|
||||||
<a href="#none">공통안내</a>
|
|
||||||
<ul class="lnb_list_nav">
|
|
||||||
<li> <a th:href="@{/apis/common}">API 개발 공통</a></li>
|
|
||||||
<li> <a th:href="@{/apis/KAPAP004U3}">가상계좌 응답 코드</a></li>
|
|
||||||
<li> <a th:href="@{/apis/KAPAP004U4}">가상계좌 배치 설계</a></li>
|
|
||||||
<li> <a th:href="@{/apis/KAPAP004U5}">가상계좌 VAN사 코드</a></li>
|
|
||||||
<li> <a th:href="@{/apis/KAPAP004U6}">펌뱅킹 응답코드</a></li>
|
|
||||||
<li> <a th:href="@{/apis/KAPAP004U7}">펌뱅킹 배치 설계</a></li>
|
|
||||||
<li> <a th:href="@{/apis/KAPAP004U8}">대출금리 응답코드</a></li>
|
|
||||||
<li> <a th:href="@{/apis/KAPAP004U9}">케이뱅크 페이 응답코드</a></li>
|
|
||||||
<li> <a th:href="@{/apis/KAPAP004U10}">케이뱅크 페이 복합과세 예제</a></li>
|
|
||||||
<li> <a th:href="@{/apis/token-spec}">케이뱅크 OAuth 2.0 토큰 발급</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li th:each="apiService : ${services}">
|
|
||||||
<a href="#none" th:text="${apiService.groupName}">API Group Name</a>
|
|
||||||
<ul class="lnb_list_nav">
|
|
||||||
<li th:each="api : ${apiService.apiGroupApiList}">
|
|
||||||
<a th:href="@{/apis/detail(id=${api.apiId})}" th:text="${api.apiDesc}">API Description</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
|
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
|
||||||
|
|
||||||
<head th:fragment="headFragment">
|
<head th:fragment="headFragment">
|
||||||
<title th:text="#{title.html}">개발자 포털</title>
|
<title th:text="#{title.html}">개발자 포털</title>
|
||||||
@@ -27,6 +28,23 @@
|
|||||||
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
|
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
|
||||||
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
|
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
|
||||||
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/>
|
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/>
|
||||||
|
|
||||||
|
<!-- 익명(비로그인) 세션 keepalive: 로그인/회원가입 등에서 페이지에 머무는 동안 주기적 ping으로
|
||||||
|
세션 비활성 타이머를 리셋해 세션 저장 CSRF 토큰·회원가입 본인인증 상태의 만료(10분)를 방지한다.
|
||||||
|
탭을 닫으면 ping이 멈춰 정상 만료. 인증 사용자는 헤더의 세션 타이머/heartbeat가 대신 처리한다. -->
|
||||||
|
<script sec:authorize="isAnonymous()" th:inline="javascript">
|
||||||
|
(function () {
|
||||||
|
var PING_URL = /*[[@{/api/session/ping}]]*/ '/api/session/ping';
|
||||||
|
var PING_INTERVAL_MS = 4 * 60 * 1000; // 세션 타임아웃(10분)의 절반 이하
|
||||||
|
setInterval(function () {
|
||||||
|
try {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('GET', PING_URL, true);
|
||||||
|
xhr.send();
|
||||||
|
} catch (ignore) { /* keepalive 실패는 화면 동작에 영향 없음 */ }
|
||||||
|
}, PING_INTERVAL_MS);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<meta content="max-age=0, public" http-equiv="Cache-Control"/>
|
<meta content="max-age=0, public" http-equiv="Cache-Control"/>
|
||||||
<meta content="index, follow" name="robots"/>
|
<meta content="index, follow" name="robots"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
|
||||||
@@ -100,9 +118,9 @@
|
|||||||
<script th:src="@{/plugins/summernote/summernote-cleaner.js}"></script>
|
<script th:src="@{/plugins/summernote/summernote-cleaner.js}"></script>
|
||||||
<script th:src="@{/plugins/jquery-ui/jquery-ui.min.js}"></script>
|
<script th:src="@{/plugins/jquery-ui/jquery-ui.min.js}"></script>
|
||||||
|
|
||||||
<!-- LiveReload (개발 전용): DevTools 가 정적 리소스/템플릿 변경을 감지하면 브라우저를 자동 새로고침한다.
|
<!--/* LiveReload (개발 전용): DevTools 가 정적 리소스/템플릿 변경을 감지하면 브라우저를 자동 새로고침한다.
|
||||||
localhost 외 IP(예: 172.30.1.14)로 접속해도 동작하도록 접속 호스트 기준으로 livereload.js 를 로드한다.
|
localhost 외 IP(예: 172.30.1.14)로 접속해도 동작하도록 접속 호스트 기준으로 livereload.js 를 로드한다.
|
||||||
prod/stage 에는 DevTools 자체가 없으므로(developmentOnly) 개발 프로파일에서만 주입한다. -->
|
prod/stage 에는 DevTools 자체가 없으므로(developmentOnly) 개발 프로파일에서만 주입한다. */-->
|
||||||
<script th:if="${@environment.acceptsProfiles('local_rinjaemac','local')}"
|
<script th:if="${@environment.acceptsProfiles('local_rinjaemac','local')}"
|
||||||
th:src="|//${#request.serverName}:35729/livereload.js|"></script>
|
th:src="|//${#request.serverName}:35729/livereload.js|"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -21,12 +21,12 @@
|
|||||||
<!-- Desktop Header Layout -->
|
<!-- Desktop Header Layout -->
|
||||||
<div class="desktop-header">
|
<div class="desktop-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<!-- <div class="logo-wrapper">-->
|
<!--/* <div class="logo-wrapper">*/-->
|
||||||
<!-- <a th:href="@{/}" class="logo-link">-->
|
<!--/* <a th:href="@{/}" class="logo-link">*/-->
|
||||||
<!-- <img src="/img/logo/logo-djb.png" alt="DJBank" class="logo" width="114" height="32">-->
|
<!--/* <img src="/img/logo/logo-djb.png" alt="DJBank" class="logo" width="114" height="32">*/-->
|
||||||
<!-- </a>-->
|
<!--/* </a>*/-->
|
||||||
<!-- <a th:href="@{/}" class="logo-text">API Portal</a>-->
|
<!--/* <a th:href="@{/}" class="logo-text">API Portal</a>*/-->
|
||||||
<!-- </div>-->
|
<!--/* </div>*/-->
|
||||||
<div >
|
<div >
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<a th:href="@{/}" class="logo-wrapper">
|
<a th:href="@{/}" class="logo-wrapper">
|
||||||
@@ -63,15 +63,15 @@
|
|||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
||||||
<!-- Login State (Anonymous) -->
|
<!-- Login State (Anonymous) -->
|
||||||
<!-- <div class="auth-group" sec:authorize="isAnonymous()">-->
|
<!--/* <div class="auth-group" sec:authorize="isAnonymous()">*/-->
|
||||||
|
|
||||||
<!-- <a th:href="@{/login}" class="login-btn login-btn-box">-->
|
<!--/* <a th:href="@{/login}" class="login-btn login-btn-box">*/-->
|
||||||
<!-- <img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">로그인</a>-->
|
<!--/* <img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">로그인</a>*/-->
|
||||||
<!-- </div>-->
|
<!--/* </div>*/-->
|
||||||
|
|
||||||
<div class="auth-group" sec:authorize="isAnonymous()">
|
<div class="auth-group" sec:authorize="isAnonymous()">
|
||||||
<a th:href="@{/login}" class="login-btn login-btn-box">로그인</a>
|
<a th:href="@{/login}" class="login-btn login-btn-box">로그인</a>
|
||||||
<a href="#" class="btn-signup">회원가입</a>
|
<a th:href="@{/signup}" class="btn-signup">회원가입</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logout State (Authenticated) -->
|
<!-- Logout State (Authenticated) -->
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li sec:authorize="hasRole('ROLE_APP')">
|
<li sec:authorize="hasRole('ROLE_APP')">
|
||||||
<a th:href="@{/myapikey}"><i class="fas fa-key"></i>인증 키 관리</a>
|
<a th:href="@{/myapikey}"><i class="fas fa-key"></i>인증 키 관리</a>
|
||||||
<a th:href="@{/webhook}"><i class="fas fa-bell"></i>Webhook 관리</a>
|
<a th:href="@{/webhook}" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"><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>
|
||||||
<li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li>
|
<li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li>
|
||||||
@@ -250,7 +250,7 @@
|
|||||||
<ul class="drawer-submenu">
|
<ul class="drawer-submenu">
|
||||||
<li sec:authorize="hasRole('ROLE_USER_MANAGER')"><a th:href="@{/users}">이용자 관리</a></li>
|
<li sec:authorize="hasRole('ROLE_USER_MANAGER')"><a th:href="@{/users}">이용자 관리</a></li>
|
||||||
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/myapikey}">인증 키 관리</a></li>
|
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/myapikey}">인증 키 관리</a></li>
|
||||||
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/webhook}">Webhook 관리</a></li>
|
<li sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"><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>
|
||||||
<li><a th:href="@{/change_password}">비밀번호 변경</a></li>
|
<li><a th:href="@{/change_password}">비밀번호 변경</a></li>
|
||||||
@@ -284,7 +284,7 @@
|
|||||||
var FORCE_LOGOUT_URL = /*[[@{/login?forceLogout=true}]]*/ '/login?forceLogout=true';
|
var FORCE_LOGOUT_URL = /*[[@{/login?forceLogout=true}]]*/ '/login?forceLogout=true';
|
||||||
var LOGOUT_URL = /*[[@{/actionLogout.do}]]*/ '/actionLogout.do';
|
var LOGOUT_URL = /*[[@{/actionLogout.do}]]*/ '/actionLogout.do';
|
||||||
|
|
||||||
var timeoutMinutes = /*[[${sessionTimeoutMinutes}]]*/ 15;
|
var timeoutMinutes = /*[[${sessionTimeoutMinutes}]]*/ 10;
|
||||||
var remainingSeconds = timeoutMinutes * 60;
|
var remainingSeconds = timeoutMinutes * 60;
|
||||||
var WARNING_SECONDS = 60; // 만료 60초 전 연장 확인 모달
|
var WARNING_SECONDS = 60; // 만료 60초 전 연장 확인 모달
|
||||||
var POLL_INTERVAL_MS = 30000; // 서버 잔여시간 동기화 주기
|
var POLL_INTERVAL_MS = 30000; // 서버 잔여시간 동기화 주기
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
<a th:href="@{/webhook}"
|
<a th:href="@{/webhook}"
|
||||||
th:classappend="${activeMenu == 'webhook'} ? 'service-nav__item--active' : ''"
|
th:classappend="${activeMenu == 'webhook'} ? 'service-nav__item--active' : ''"
|
||||||
class="service-nav__item"
|
class="service-nav__item"
|
||||||
sec:authorize="hasRole('ROLE_APP')">Webhook 관리</a>
|
sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">Webhook 관리</a>
|
||||||
|
|
||||||
<a th:href="@{/statistics/api}"
|
<a th:href="@{/statistics/api}"
|
||||||
th:classappend="${activeMenu == 'statistics'} ? 'service-nav__item--active' : ''"
|
th:classappend="${activeMenu == 'statistics'} ? 'service-nav__item--active' : ''"
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
|
||||||
|
|
||||||
<head th:fragment="headFragment">
|
|
||||||
<title th:text="#{title.html}">개발자 포털</title>
|
|
||||||
|
|
||||||
<meta content="https://www.eactive.co.kr/" property="og:url"/>
|
|
||||||
<meta charset="UTF-8"/>
|
|
||||||
<meta content="max-age=0, public" http-equiv="Cache-Control"/>
|
|
||||||
<meta content="index, follow" name="robots"/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
|
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge">
|
|
||||||
<meta http-equiv="Cache-Control" content="no-cache">
|
|
||||||
<meta http-equiv="Pragma" content="no-cache">
|
|
||||||
|
|
||||||
<meta http-equiv="Expires" content="0">
|
|
||||||
<link rel="stylesheet" type="text/css" th:href="@{/css/style2.css}">
|
|
||||||
<link rel="stylesheet" type="text/css" th:href="@{/css/common2.css}">
|
|
||||||
<link rel="stylesheet" type="text/css" th:href="@{/css/slick.css}">
|
|
||||||
<link rel="stylesheet" type="text/css" th:href="@{/css/daterangepicker.css}">
|
|
||||||
|
|
||||||
<link rel="stylesheet" th:href="@{/plugins/codemirror/codemirror.css}" type="text/css"/>
|
|
||||||
<link rel="stylesheet" th:href="@{/plugins/codemirror/theme/monokai.css}" type="text/css"/>
|
|
||||||
<link rel="stylesheet" th:href="@{/plugins/summernote/summernote-lite.css}" type="text/css" />
|
|
||||||
<link rel="stylesheet" th:href="@{/plugins/jquery-ui/jquery-ui.min.css}" />
|
|
||||||
|
|
||||||
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
|
||||||
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
|
||||||
<!--[if lt IE 9]>
|
|
||||||
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
|
|
||||||
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
|
|
||||||
<![endif]-->
|
|
||||||
|
|
||||||
<script th:src="@{/plugins/jquery/jquery-3.7.1.min.js}"></script>
|
|
||||||
<script th:src="@{/js/lodash.js}"></script>
|
|
||||||
|
|
||||||
<!-- Portal 설정값 전역 노출 -->
|
|
||||||
<script th:inline="javascript">
|
|
||||||
window.PORTAL_CONFIG = {
|
|
||||||
file: {
|
|
||||||
maxSize: /*[[${@portalProperties.file.maxSize}]]*/ '8MB',
|
|
||||||
maxSizeBytes: /*[[${@portalProperties.file.maxSizeBytes}]]*/ 8388608,
|
|
||||||
allowedExtensions: /*[[${@portalProperties.file.allowedExtensions}]]*/ 'pdf,doc,docx,xls,xlsx,ppt,pptx,hwp,gif,jpg,jpeg,png'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script th:src="@{/js/common.js}"></script>
|
|
||||||
<script th:src="@{/js/moment.min.js}"></script>
|
|
||||||
<script th:src="@{/js/daterangepicker.js}"></script>
|
|
||||||
<script th:src="@{/plugins/codemirror/codemirror.js}"></script>
|
|
||||||
<script th:src="@{/plugins/codemirror/mode/clike.js}"></script>
|
|
||||||
<script th:src="@{/plugins/codemirror/mode/javascript.js}"></script>
|
|
||||||
<script th:src="@{/plugins/codemirror/addon/display/fullscreen.js}"></script>
|
|
||||||
<script th:src="@{/plugins/codemirror/addon/display/placeholder.js}"></script>
|
|
||||||
<script th:src="@{/plugins/summernote/summernote-lite.min.js}"></script>
|
|
||||||
<script th:src="@{/plugins/summernote/summernote-cleaner.js}"></script>
|
|
||||||
<script th:src="@{/plugins/jquery-ui/jquery-ui.min.js}"></script>
|
|
||||||
<script th:src="@{/js/htmx.min.js}"></script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
</container-descriptor>
|
</container-descriptor>
|
||||||
|
|
||||||
<session-descriptor>
|
<session-descriptor>
|
||||||
<timeout-secs>1800</timeout-secs>
|
<!-- 세션 타임아웃 10분 고정 (application.yml server.servlet.session.timeout=10m 과 동일 값 유지) -->
|
||||||
|
<timeout-secs>600</timeout-secs>
|
||||||
<cookie-name>JSESSIONID_PORTAL</cookie-name>
|
<cookie-name>JSESSIONID_PORTAL</cookie-name>
|
||||||
<persistent-store-type>replicated_if_clustered</persistent-store-type>
|
<persistent-store-type>replicated_if_clustered</persistent-store-type>
|
||||||
</session-descriptor>
|
</session-descriptor>
|
||||||
|
|||||||
Reference in New Issue
Block a user