18 Commits

Author SHA1 Message Date
Rinjae dfc859c9cf 승인 요청 처리 개선 및 UI/로직 추가:
- 승인정보 없는 요청 목록 노출 및 삭제 로직 추가
- 내부 결재 진행 중 취소 불가 안내 메시지 및 스타일 추가
- '승인대기' -> '승인정보 없음' 텍스트 수정
2026-07-24 17:22:40 +09:00
Rinjae 24c669b0be 접근 권한 처리 및 권한 설정 개선:
- 미로그인 사용자 접근 시 로그인 페이지 리다이렉트 처리
- Webhook 관리 접근 권한 `ROLE_API_KEY_REQUEST`로 변경
- 오류 페이지 스타일 수정: `white-space: pre-line` 추가
2026-07-24 13:14:17 +09:00
hong 3eedc522e2 merge 충돌수정 2026-07-24 10:24:07 +09:00
hong a361cc3dec fix : open api , 공지사항 반응형 퍼블리싱 2026-07-24 10:19:18 +09:00
Rinjae 9981459691 "SwaggerUI - 실행 전 응답 UI 노출 방지 로직 추가
- Execute 클릭 여부 확인 pendingExecute 플래그 도입
- 네트워크 에러/응답 처리 시 플래그 초기화 추가"
2026-07-24 09:44:28 +09:00
Rinjae a2878f9cee Merge branch 'master' into design 2026-07-23 20:58:03 +09:00
Rinjae a6807a37c2 - API 감사 로거 추가 - APITesterAuditLogger 클래스 생성
- 회원가입 링크 수정 - href 속성 추가
- Testbed UI 스타일 추가 - CSS/SASS 코드 업데이트
2026-07-23 20:28:12 +09:00
Rinjae 1f10dda993 - API 감사 로거 추가 - APITesterAuditLogger 클래스 생성
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 회원가입 링크 수정 - href 속성 추가
- Testbed UI 스타일 추가 - CSS/SASS 코드 업데이트
2026-07-23 20:28:12 +09:00
Rinjae 6c10ae12c7 Merge branch 'master' into design 2026-07-23 20:26:55 +09:00
Rinjae 01c4a80182 API 테스트베드 개선 및 감사 로그 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Gateway 프로퍼티 mock 토큰 관련 항목 추가
- API 요청/응답 감사 로그 작성 로직 구현 및 로깅 설정 추가
- Swagger UI 전용 spec 제공 및 서버 주소 치환 로직 분리
2026-07-23 20:26:40 +09:00
Rinjae a5611cf775 - 서비스 가이드 컬럼 레이아웃 개선: 1열 배치로 변경
- 다국어 코드 블럭 간격 문제 수정 및 SASS 재컴파일
- 버튼 및 배지 hover 컬러 RGB % 표기로 변경
2026-07-23 18:12:12 +09:00
Rinjae c733e6b200 Merge branch 'master' into design
# Conflicts:
#	src/main/resources/templates/views/apps/service/oauth2-guide.html
2026-07-23 18:09:00 +09:00
Rinjae a2813f391c - OAuth2 가이드 scope 고정값 "api" 반영(master 이식)
- 인증 헤더 표기 X-AUTH-TOKEN 으로 수정
- 가이드 히어로/사전준비/엔드포인트 1열 배치 전환
- webhook 개발가이드 문구 정리, SASS 재컴파일

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:07:58 +09:00
Rinjae f221c20ece - 세션 타임아웃 10분 고정: 서버/DB 설정 통일 및 관리 단순화
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- CSRF 토큰 조회/재발급 API 추가 - 익명 세션 유지 핑 로직 구현
- OAuth2 가이드 scope 고정값 "api"로 수정
2026-07-23 18:05:57 +09:00
Rinjae 4a3b5b43f3 불필요 주석 블록 처리:
- HTML 템플릿 주석 CSS 규격으로 통일
- guide.html 외 10개 파일 변경
- editor-content 클래스 관련 설명 추가
2026-07-22 15:51:43 +09:00
Rinjae 8de0d94063 API 관련 HTML 템플릿 파일 제거 및 상세 뷰 클래스 추가:
- api_lnb.html, head.html 템플릿 삭제
- API 상세 페이지에 editor-content 클래스 추가
2026-07-22 14:40:12 +09:00
Rinjae 57d4efb4eb Merge branch 'master' into design 2026-07-22 11:09:13 +09:00
Rinjae c865359819 사용자 이메일 마스킹 처리 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- PortalConfigLog에 StringMaskingUtil 적용
- 로그인 사용자 아이디 마스킹 처리(Log 개선)
2026-07-22 09:39:09 +09:00
50 changed files with 2048 additions and 1067 deletions
@@ -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);
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 방지)
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 헤더가 없습니다.\"}");
return;
}
@@ -84,16 +92,15 @@ public class ApiTesterFilter implements Filter {
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|| url.contains(gatewayProperty.tokenPath());
// 본문은 한 번만 읽어 프록시 forward 와 감사 로그에 함께 사용 (GET 이면 빈 문자열)
String requestBody = readBody(httpServletRequest);
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url, gatewayMode.name(), tokenRequest, requestBody);
if (tokenRequest) {
StringBuilder sb = new StringBuilder();
BufferedReader reader = httpServletRequest.getReader();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
String body = sb.toString();
String body = requestBody;
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
auditType = "TOKEN_MOCK";
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
Map<String, String> params = new HashMap<>();
String[] pairs = body.split("&");
@@ -106,7 +113,7 @@ public class ApiTesterFilter implements Filter {
String scope = params.getOrDefault("scope", "default");
String token = "{\n" +
" \"access_token\": \"djbank_gw_sample_token\",\n" +
" \"access_token\": \"" + escapeJson(gatewayProperty.mockAccessToken()) + "\",\n" +
" \"token_type\": \"bearer\",\n" +
" \"expires_in\": 86400,\n" +
" \"scope\": \""+scope +"\",\n" +
@@ -117,6 +124,7 @@ public class ApiTesterFilter implements Filter {
response.getWriter().println(token);
} else {
// GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만)
auditType = "TOKEN_GW";
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", "application/x-www-form-urlencoded");
@@ -133,6 +141,7 @@ public class ApiTesterFilter implements Filter {
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
if (apiSpecInfoDto == null) {
auditType = "SPEC_NOT_FOUND";
writeJson(response, HttpServletResponse.SC_NOT_FOUND,
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
return;
@@ -142,6 +151,7 @@ public class ApiTesterFilter implements Filter {
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
if (responseType == null || responseType.equalsIgnoreCase("sample")) {
auditType = "SAMPLE";
response.setContentType("application/json");
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
return;
@@ -151,6 +161,7 @@ public class ApiTesterFilter implements Filter {
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
// - mock : ApiSpecInfo.mockUrl (기존 동작)
boolean gw = "gw".equalsIgnoreCase(responseType);
auditType = gw ? "GW" : "MOCK";
Map<String, String> headers = new HashMap<>();
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
@@ -160,6 +171,10 @@ public class ApiTesterFilter implements Filter {
}
headers.remove("original-url");
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;
Map<String, String[]> paramMap;
@@ -179,7 +194,7 @@ public class ApiTesterFilter implements Filter {
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
String responseStr;
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
responseStr = apiSender.requestPost(targetUri, headers, paramMap, readBody(httpServletRequest));
responseStr = apiSender.requestPost(targetUri, headers, paramMap, requestBody);
} else {
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
}
@@ -202,6 +217,9 @@ public class ApiTesterFilter implements Filter {
logger.error("테스트베드 프록시 처리 오류", e);
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"{\"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;
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.service.ApiService;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
@@ -178,7 +179,6 @@ public class MyAppController {
model.addAttribute("apiKey", apiKey);
model.addAttribute("secretAvailable", secretAvailable);
model.addAttribute("authType", "OAuth2");
return new ModelAndView(CREDENTIAL_DETAIL);
}
@@ -217,9 +217,17 @@ public class MyAppController {
appServiceFacade.cancelApiRequest(id, SecurityUtil.getPortalAuthenticatedUser().getPortalOrg());
result.put("success", true);
result.put("message", "신청이 취소되었습니다.");
} catch (Exception e) {
} catch (InvalidApprovalTransitionException e) {
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;
@@ -59,6 +59,7 @@ public class AppServiceFacade {
private final ApiServiceHelper apiServiceHelper;
private final FileService fileService;
private final PasswordEncoder passwordEncoder;
private final AdminGatewayClient adminGatewayClient;
public List<ClientDTO> getApikeyList(PortalOrg portalOrg) {
@@ -68,9 +69,13 @@ public class AppServiceFacade {
}
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()));
// 승인정보(approval) 없는 신청도 목록에 노출한다. (사용자가 직접 삭제 가능)
appRequests.addAll(appRequestRepository.findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
return appRequests;
}
@@ -134,7 +139,23 @@ public class AppServiceFacade {
}
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);
for (String apiId : apiList) {
ApiServiceDTO serviceDTO = mainIconsMap.get(apiId);
// 신청 이후 API 스펙/그룹이 삭제된 경우 null 가능
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);
}
}
@@ -5,6 +5,7 @@ import com.eactive.apim.portal.apps.session.service.UserSessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -25,6 +26,8 @@ import java.util.Optional;
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
* <li>GET /api/session/ping - 익명 세션 keepalive (로그인/회원가입 페이지)</li>
* <li>GET /api/session/csrf - 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망)</li>
* </ul>
*/
@Slf4j
@@ -109,6 +112,37 @@ public class SessionApiController {
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번째 옥텟을 ***로 치환)
* 예: 192.168.240.178 → 192.168.***.178
@@ -20,8 +20,9 @@ import java.util.Optional;
public class UserSessionService {
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)에서 적용 */
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() {
String value = portalPropertyService.getOrCreateProperty(
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);
}
return SESSION_TIMEOUT_MINUTES;
}
/**
@@ -62,7 +62,7 @@ public class GlobalControllerAdvice {
}
/**
* 화면 세션 타이머 기준이 되는 타임아웃(분). PortalProperty(Portal/session.timeout.minutes)에서 조회.
* 화면 세션 타이머 기준이 되는 타임아웃(분). 10분 고정 (UserSessionService.SESSION_TIMEOUT_MINUTES).
*/
@ModelAttribute("sessionTimeoutMinutes")
public int sessionTimeoutMinutes() {
@@ -7,6 +7,7 @@ import java.util.Map;
import java.util.stream.Collectors;
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.file.exception.InvalidFileException;
import lombok.RequiredArgsConstructor;
@@ -54,7 +55,15 @@ public class PortalGlobalExceptionHandler {
@ExceptionHandler(value = AccessDeniedException.class)
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)
@@ -122,8 +122,8 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
clientIp, request.getHeader("User-Agent"));
// 물리 세션 타임아웃을 DB property(Portal/session.timeout.minutes)와 일치시킴.
// yml/weblogic.xml 기본값을 이 세션에 대해 override → 물리=논리 단일화(CSRF 수명 포함).
// 물리 세션 타임아웃 10분 고정. yml(timeout: 10m)·weblogic.xml(timeout-secs 600)과 동일 값이지만
// 컨테이너 설정(콘솔 override 등)과 무관하게 보장하기 위해 명시 적용 → 물리=논리 단일화(CSRF 수명 포함).
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.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
@@ -32,7 +33,7 @@ public class PortalConfigLog {
String query = request.getQueryString();
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);
Logger logger = LoggerFactory.getLogger(joinPoint.getTarget().getClass());
@@ -86,6 +86,7 @@ public class PortalConfigSecurity {
// 운영(prod/eapim/devportal)은 동일 호스트(IP:PORT)에 여러 서비스가 떠 있어
// 쿠키가 호스트 단위로 공유·과포화되면서 XSRF-TOKEN 쿠키가 누락 → 로그인 403이 발생했다.
// 기존 클라이언트(X-XSRF-TOKEN 헤더, _csrf 파라미터)와 호환되도록 헤더명을 고정한다.
// 세션에 저장되므로 CSRF 토큰 수명은 세션 타임아웃(10분)과 동일하다.
HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository();
csrfTokenRepository.setHeaderName("X-XSRF-TOKEN");
@@ -32,6 +32,7 @@ public class DjbTestbedGatewayProperty {
public static final String KEY_TIMEOUT_SEC = "djb.gateway.timeout";
public static final String KEY_USE_PROXY = "djb.gateway.use-proxy";
public static final String KEY_TOKEN_USE_PROXY = "djb.gateway.token-use-proxy";
public static final String KEY_MOCK_ACCESS_TOKEN = "djb.gateway.mock-access-token";
public static final String DEFAULT_BASE_URL = "PortalMock";
public static final String DEFAULT_TIMEOUT_SEC = "10";
@@ -47,6 +48,9 @@ public class DjbTestbedGatewayProperty {
/** PortalMock 모드에서 사용하는 포털 기존 mock 토큰 경로. */
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() {
return resolve(KEY_BASE_URL, DEFAULT_BASE_URL,
"GW Base URL. 문자열 \"PortalMock\" 이면 ApiTesterFilter 가 mock 토큰 반환");
@@ -86,6 +90,12 @@ public class DjbTestbedGatewayProperty {
"테스트베드 토큰 발급 시 서버 프록시(/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} 값도 자동 변환한다.
* {@code true}/{@code Y} → true, {@code false}/{@code N} → false, 그 외/null → {@code def}.
@@ -25,8 +25,11 @@ import org.springframework.web.bind.annotation.RestController;
/**
* 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 대상 외).
*/
@RestController
@@ -45,22 +48,34 @@ public class DjbTestbedSpecController {
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request);
String json = buildSpecJson(id, request, true);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
}
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
public ResponseEntity<String> swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request);
String json = buildSpecJson(id, request, true);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
}
/** default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null. */
private String buildSpecJson(String id, HttpServletRequest request) throws IOException {
/** Swagger UI 전용 spec — 서버 주소를 responseType(sample/mock/gw) 설정에 따라 치환. */
@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)) {
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
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);
@@ -70,6 +85,8 @@ public class DjbTestbedSpecController {
DjbAuthType authType = authService.resolveAuthType(id);
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);
}
}
@@ -50,6 +50,22 @@ public class DjbTestbedSpecServerRewriter {
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 원본 반환. */
public String toYaml(String specJson) {
try {
+4 -5
View File
@@ -2,11 +2,10 @@ server:
servlet:
context-path: /
session:
# 물리 세션 타임아웃은 DB PortalProperty(Portal/session.timeout.minutes)로 관리한다.
# 로그인 성공 시 PortalAuthenticationSuccessHandler 가
# session.setMaxInactiveInterval(session.timeout.minutes * 60) 으로 적용 → 물리=논리 일치.
# 익명/로그인 전 세션은 컨테이너 기본값으로 fallback (weblogic.xml <timeout-secs>1800).
# timeout: 10m
# 세션 타임아웃 10분 고정 (DB property 관리 폐지).
# WebLogic 배포 시에는 weblogic.xml <timeout-secs>600 이 동일 값을 적용한다.
# CSRF 토큰은 세션에 저장(HttpSessionCsrfTokenRepository)되므로 수명도 이 값과 동일하다.
timeout: 10m
cookie:
name: JSESSIONID_PORTAL
encoding:
+17
View File
@@ -56,6 +56,19 @@
</encoder>
</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">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>${CONSOLE_EFFECTIVE_LEVEL}</level>
@@ -68,6 +81,10 @@
<appender-ref ref="HTTP_SESSION" />
</logger>
<logger name="eapim.portal.apitester.audit" level="INFO" additivity="false">
<appender-ref ref="API_TESTER_AUDIT" />
</logger>
<root level="INFO">
<appender-ref ref="ROLLING"/>
+353 -120
View File
@@ -693,7 +693,7 @@ hr {
--accent-orange: #FF6B6B;
--accent-green: #6BCF7F;
--accent-purple: #A78BFA;
--accent-color:#efdcb2;
--accent-color: #efdcb2;
--accent-light: #E9F9FF;
--text-dark: #1A1A2E;
--text-gray: #64748B;
@@ -1490,7 +1490,7 @@ body.design-survey-active .global-header {
.nav-menu {
display: flex;
list-style: none;
gap: 36px;
gap: 28px;
margin: 0;
padding: 0;
}
@@ -1545,7 +1545,7 @@ body.design-survey-active .global-header {
font-size: 16px;
font-weight: 500;
color: var(--text-gray);
padding: 8px 0;
padding: 8px;
position: relative;
transition: var(--transition-smooth);
}
@@ -6028,7 +6028,7 @@ select.form-control {
width: 100%;
}
.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 {
display: none;
@@ -6521,7 +6521,8 @@ select.form-control {
width: 20px;
height: 20px;
}
.pagination .pagination-btn i, .pagination .pagination-btn svg {
.pagination .pagination-btn i,
.pagination .pagination-btn svg {
font-size: 14px;
width: 14px;
height: 14px;
@@ -10226,32 +10227,38 @@ body.index-page-body {
padding: 20px;
}
}
@media (max-width: 768px) {
.api-market-container .service-sidebar {
position: fixed;
left: 0;
top: 0;
height: 100vh;
width: 280px;
margin: 0;
z-index: 1000;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: #ffffff;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
padding: 80px 24px 30px;
}
.api-market-container .service-sidebar.mobile-open {
transform: translateX(0);
}
.api-market-container .service-sidebar .service-nav {
flex-direction: column !important;
gap: 16px !important;
border: none !important;
padding: 0 !important;
box-shadow: none !important;
background: transparent !important;
.api-market-container {
/*
.service-sidebar {
@media (max-width: 768px) {
position: fixed;
left: 0;
top: 0;
height: 100vh;
width: 280px;
margin: 0;
z-index: 1000;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: #ffffff;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
padding: 80px 24px 30px; // Clear header height if any
&.mobile-open {
transform: translateX(0);
}
.service-nav {
flex-direction: column !important;
gap: 16px !important;
border: none !important;
padding: 0 !important;
box-shadow: none !important;
background: transparent !important;
}
}
}
*/
}
.api-market-hero {
@@ -10471,6 +10478,7 @@ body.index-page-body {
.api-market-content {
flex: 1;
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
@@ -10491,35 +10499,7 @@ body.index-page-body {
}
.api-mobile-toggle {
display: none;
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;
}
display: none !important;
}
.api-market-header {
@@ -10815,6 +10795,124 @@ body.index-page-body {
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 {
background: transparent;
border-radius: 0;
@@ -10907,12 +11005,12 @@ body.index-page-body {
padding-bottom: 0;
border-bottom: none;
}
.api-detail-card .detail-content {
.api-detail-card .detail-content:not(.editor-content) {
font-size: 14px;
color: #64748B;
line-height: 1.6;
}
.api-detail-card .detail-content pre {
.api-detail-card .detail-content:not(.editor-content) pre {
background-color: #F8FAFC;
border: 1px solid #E2E8F0;
border-radius: 8px;
@@ -10920,14 +11018,14 @@ body.index-page-body {
overflow-x: auto;
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-size: 14px;
color: #1A1A2E;
white-space: pre-wrap;
word-break: break-word;
}
.api-detail-card .detail-content table {
.api-detail-card .detail-content:not(.editor-content) table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
@@ -10936,10 +11034,10 @@ body.index-page-body {
border-radius: 8px;
overflow: hidden;
}
.api-detail-card .detail-content table thead {
.api-detail-card .detail-content:not(.editor-content) table thead {
background-color: #EFF6FF;
}
.api-detail-card .detail-content table th {
.api-detail-card .detail-content:not(.editor-content) table th {
text-align: left;
padding: 8px 16px;
font-size: 14px;
@@ -10948,28 +11046,28 @@ body.index-page-body {
border-bottom: 2px solid #E2E8F0;
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;
}
.api-detail-card .detail-content table td {
.api-detail-card .detail-content:not(.editor-content) table td {
padding: 8px 16px;
font-size: 14px;
color: #64748B;
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;
}
.api-detail-card .detail-content table tr {
.api-detail-card .detail-content:not(.editor-content) table tr {
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;
}
.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);
}
.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);
}
@@ -11076,27 +11174,31 @@ body.index-page-body {
font-size: 16px;
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;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.api-detail-card .detail-content table {
.api-detail-card .detail-content:not(.editor-content) table {
min-width: 500px;
margin: 8px 0;
}
.api-detail-card .detail-content table th,
.api-detail-card .detail-content table td {
.api-detail-card .detail-content:not(.editor-content) table th,
.api-detail-card .detail-content:not(.editor-content) table td {
padding: 4px 8px;
font-size: 12px;
white-space: nowrap;
}
.api-detail-card .detail-content pre {
.api-detail-card .detail-content:not(.editor-content) pre {
padding: 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.api-detail-card .detail-content pre code {
.api-detail-card .detail-content:not(.editor-content) pre code {
font-size: 12px;
white-space: pre;
word-break: normal;
@@ -11138,9 +11240,12 @@ body.index-page-body {
overflow-x: hidden;
}
.api-market-content {
max-width: 100vw;
width: 100%;
overflow-x: hidden;
}
.api-card {
height: 285px;
}
}
.login-page {
display: flex;
@@ -17741,6 +17846,12 @@ input[type=checkbox]:checked + .custom-checkbox {
font-size: 14px;
margin: 0;
}
.detail-wrap .dt-cancel-notice {
margin: 10px 0 0;
text-align: center;
font-size: 14px;
color: #64748b;
}
.detail-wrap .dt-actions {
display: flex;
justify-content: center;
@@ -18364,6 +18475,14 @@ input[type=checkbox]:checked + .custom-checkbox {
color: #0a4ea3;
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 {
font-size: 28px;
@@ -18421,22 +18540,27 @@ input[type=checkbox]:checked + .custom-checkbox {
.notice-attachment-link .attachment-filename {
font-size: 14px;
color: #666;
word-break: break-all;
}
.notice-detail-incident-info {
.notice-detail-incident-info,
.notice-detail-affected-info {
margin-bottom: 20px;
}
.notice-detail-incident-info .detail-table {
.notice-detail-incident-info .detail-table,
.notice-detail-affected-info .detail-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
border-top: none;
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;
}
.notice-detail-incident-info .detail-table th {
.notice-detail-incident-info .detail-table th,
.notice-detail-affected-info .detail-table th {
background: #f9f9f9;
height: 43px;
font-weight: 500;
@@ -18444,12 +18568,114 @@ input[type=checkbox]:checked + .custom-checkbox {
text-align: center;
color: #000;
}
.notice-detail-incident-info .detail-table td {
padding-left: 19px;
.notice-detail-incident-info .detail-table td,
.notice-detail-affected-info .detail-table td {
padding: 10px 19px;
font-size: 14px;
color: #000;
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 {
padding: 20px 0 60px 0;
@@ -18507,35 +18733,8 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.btn-notice-list {
display: flex;
align-items: center;
justify-content: center;
width: 247px;
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 {
@@ -22637,12 +22836,39 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.service-sidebar {
width: 100%;
overflow: hidden;
}
.service-nav {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
padding: 20px;
flex-direction: row !important;
flex-wrap: nowrap !important;
justify-content: flex-start !important;
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,
.oauth2-2legged__hero {
@@ -23249,7 +23475,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.oauth2-2legged__prereq-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-template-columns: 1fr;
gap: 16px;
}
.oauth2-2legged__prereq-card {
@@ -23311,17 +23537,23 @@ input[type=checkbox]:checked + .custom-checkbox {
flex-direction: column;
gap: 24px;
}
.oauth2-2legged__step-grid + .oauth2-2legged__step-grid {
margin-top: 24px;
}
.oauth2-2legged__endpoint-box {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 16px;
grid-template-columns: 1fr;
gap: 8px;
padding: 16px 24px;
margin-bottom: 20px;
background: #FFFFFF;
border: 1px solid #E2E8F0;
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 {
display: inline-block;
padding: 5px 14px;
@@ -23448,6 +23680,7 @@ input[type=checkbox]:checked + .custom-checkbox {
background: #1A1A2E;
overflow: hidden;
min-width: 0;
margin-top: 24px;
}
.oauth2-2legged__code-panel::before {
content: "";
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -26,6 +26,9 @@
var startTs = 0;
var targetOpblock = null;
// Execute 클릭 후에만 true. spec 로딩 등 실행 외 응답이 responseInterceptor 로
// 들어와도 그리드를 만들지 않기 위한 게이트 (실행 전 UI 미노출 보장).
var pendingExecute = false;
function now() {
return (global.performance && performance.now) ? performance.now() : Date.now();
@@ -206,6 +209,7 @@
// 네트워크/CORS 실패 렌더: responseInterceptor 로 오지 않고 store 에만 error 로 남는 케이스.
function renderNetworkError(msg) {
pendingExecute = false;
var grid = gridFor(targetOpblock);
if (!grid) return;
grid.classList.remove("djb-empty");
@@ -250,6 +254,7 @@
}
startTs = now();
pendingExecute = true;
var grid = gridFor(targetOpblock);
if (grid) {
grid.classList.add("djb-empty");
@@ -264,10 +269,13 @@
*/
function renderResponse(res) {
if (!res) return;
// spec 로딩 응답(/…/swagger.json)은 responseInterceptor 로도 들어온다. 이때 렌더하면
// 실행 전인데 응답 본문에 OpenAPI spec 이 그려지므로 무시한다. 사용자 API 호출은
// 프록시(/api/call-api)로 나가므로 url 에 swagger.json 이 없다.
// Execute 를 누른 적 없으면 무시 — spec 로딩(/…/swagger.json) 응답도
// responseInterceptor 로 들어오는데, 이때 렌더하면 실행 전 응답 본문에
// OpenAPI spec 이 그려진다. url 검사는 버전에 따라 res.url 이 비어 무력화될
// 수 있어 실행 게이트를 1차 방어로 둔다.
if (!pendingExecute) return;
if (res.url && res.url.indexOf("swagger.json") !== -1) return;
pendingExecute = false;
var ms = Math.max(0, Math.round(now() - startTs));
var status = res.status || 0;
var statusText = res.statusText || "";
@@ -12,5 +12,5 @@ html {
body {
margin: 0;
background: #fafafa;
background: #ffffff;
}
@@ -282,7 +282,7 @@
.board-table {
width: 100%;
border-top: 1px solid #c4c7c8;
&-wrapper {
overflow-x: auto;
margin-bottom: $spacing-xl;
@@ -351,7 +351,7 @@
@media (max-width: $breakpoint-sm) {
justify-content: flex-start;
width: 100% !important;
&::before {
content: attr(data-label);
font-weight: 500;
@@ -364,9 +364,11 @@
&--title {
justify-content: flex-start;
white-space: normal;
@media (max-width: $breakpoint-sm) {
&::before { display: none; }
&::before {
display: none;
}
}
.notice-title-link {
@@ -376,9 +378,9 @@
color: inherit;
text-decoration: none;
width: 100%;
&:hover {
text-decoration: underline;
font-weight: 600;
}
// Mobile number prefix [1] [2] etc
@@ -813,6 +815,7 @@
// 공지사항 모바일 디자인 - 검색창, 목록, 페이지네이션
// -----------------------------------------------------------------------------
@media (max-width: $breakpoint-sm) {
// 검색 필드 - Figma: 335px × 40px, border-radius 8px
.search-field {
display: flex;
@@ -995,7 +998,8 @@
width: 20px;
height: 20px;
i, svg {
i,
svg {
font-size: 14px;
width: 14px;
height: 14px;
@@ -1019,4 +1023,4 @@
}
}
}
}
}
@@ -18,7 +18,7 @@
--accent-orange: #FF6B6B;
--accent-green: #6BCF7F;
--accent-purple: #A78BFA;
--accent-color:#efdcb2;
--accent-color: #efdcb2;
--accent-light: #E9F9FF;
// Base colors
@@ -38,7 +38,7 @@
--shadow-hover: 0 30px 60px -10px rgba(0, 73, 180, 0.2);
// border
--border-color : #E2E8F0;
--border-color: #E2E8F0;
//transition
--transition-smooth: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
@@ -225,19 +225,19 @@ body.design-survey-active {
display: flex;
align-items: center;
gap: 12px;
font-size:24px;
font-size: 24px;
.logo-text-bold {
font-weight: 700;
color: var(--primary-color);
letter-spacing: -0.5px;
font-weight: 700;
color: var(--primary-color);
letter-spacing: -0.5px;
}
.logo-text-thin {
font-weight: 300;
color: var(--secondary-color);
margin-left: 4px;
font-weight: 300;
color: var(--secondary-color);
margin-left: 4px;
}
}
@@ -999,7 +999,7 @@ body.design-survey-active {
.nav-menu {
display: flex;
list-style: none;
gap: 36px;
gap: 28px;
margin: 0;
padding: 0;
@@ -1064,7 +1064,7 @@ body.design-survey-active {
font-size: 16px;
font-weight: 500;
color: var(--text-gray);
padding: 8px 0;
padding: 8px;
position: relative;
transition: var(--transition-smooth);
@@ -23,6 +23,8 @@
}
// Sidebar overrides for mobile toggle menu inside market
// Commented out to allow horizontal scrollbar on mobile screens as well
/*
.service-sidebar {
@media (max-width: 768px) {
position: fixed;
@@ -52,6 +54,7 @@
}
}
}
*/
}
// Hero Section
@@ -311,6 +314,7 @@
flex: 1;
// padding-left: $spacing-sm;
// padding-right: $spacing-sm;
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
@@ -334,37 +338,7 @@
// Mobile Menu Toggle
.api-mobile-toggle {
display: none;
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;
}
display: none !important;
}
// 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 {
background: transparent;
@@ -803,7 +910,11 @@
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;
color: $text-gray;
line-height: $line-height-normal;
@@ -1013,8 +1124,16 @@
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 {
// 과거 회귀: :not(.editor-content) 제거
.detail-content:not(.editor-content) {
font-size: $font-size-xs;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
@@ -1097,7 +1216,11 @@
// API Market Content - Mobile (화면 폭 제한)
.api-market-content {
max-width: 100vw;
width: 100%;
overflow-x: hidden;
}
.api-card {
height: 285px;
}
}
@@ -1232,6 +1232,14 @@
margin: 0;
}
// 내부 결재 진행중: 신청 취소 불가 안내
.dt-cancel-notice {
margin: 10px 0 0;
text-align: center;
font-size: 14px;
color: #64748b;
}
// Footer Buttons layout
.dt-actions {
display: flex;
+115 -31
View File
@@ -158,6 +158,16 @@
color: #0a4ea3;
border: 1px solid #b3d4ff;
}
&.lg {
height: 28px;
width: auto;
min-width: 50px;
padding: 0 10px;
font-size: 13px;
font-weight: 700;
}
}
.notice-detail-title {
@@ -222,11 +232,13 @@
.attachment-filename {
font-size: 14px;
color: #666;
word-break: break-all;
}
}
// Incident Info Table
.notice-detail-incident-info {
.notice-detail-incident-info,
.notice-detail-affected-info {
margin-bottom: 20px;
.detail-table {
@@ -250,11 +262,112 @@
}
td {
padding-left: 19px;
padding: 10px 19px;
font-size: 14px;
color: #000;
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
.btn-notice-list {
display: flex;
align-items: center;
justify-content: center;
width: 247px;
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
@@ -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 {
width: 100%;
overflow: hidden;
}
.service-nav {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
padding: 20px;
flex-direction: row !important;
flex-wrap: nowrap !important;
justify-content: flex-start !important;
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로 보정
@@ -1008,7 +1037,7 @@ $o2leg-err-fg: #a23b3b;
// -- Section 2: 사전 준비 ----------------------------------------------------
&__prereq-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-template-columns: 1fr;
gap: 16px;
}
@@ -1079,18 +1108,28 @@ $o2leg-err-fg: #a23b3b;
display: flex;
flex-direction: column;
gap: 24px;
// 연속 배치된 grid 사이 간격을 내부 gap(24px)과 동일하게 유지
// (언어별 코드 블럭이 두 grid 에 나뉘어 있어 간격이 달라 보이는 문제 방지)
+ .oauth2-2legged__step-grid {
margin-top: 24px;
}
}
&__endpoint-box {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 16px;
grid-template-columns: 1fr;
gap: 8px;
padding: 16px 24px;
margin-bottom: 20px;
background: #FFFFFF;
border: 1px solid $o2leg-border;
border-radius: 12px;
.oauth2-2legged__method,
.oauth2-2legged__endpoint-content-type {
justify-self: start;
}
}
&__method {
@@ -1244,6 +1283,7 @@ $o2leg-err-fg: #a23b3b;
background: $o2leg-text-dark;
overflow: hidden;
min-width: 0;
margin-top: 24px;
&::before {
content: "";
@@ -13,31 +13,24 @@
</div>
</th:block>
<th:block layout:fragment="contentFragment">
<section class="api-market-container">
<section class="container api-market-container">
<!-- Sidebar Navigation -->
<aside class="api-market-sidebar" id="apiSidebar">
<div class="api-sidebar-header">
<img th:src="@{/img/api_sidebar.png}" alt="API">
</div>
<nav class="api-sidebar-nav">
<aside class="service-sidebar" id="apiSidebar">
<nav class="service-nav">
<!-- All APIs -->
<div class="menu-section">
<a class="menu-title" th:classappend="${selected == '-1'} ? 'active'" th:href="@{/apis}">
전체
</a>
</div>
<a class="service-nav__item"
th:classappend="${selected == '-1' || #request.getParameter('selected') == '-1'} ? 'service-nav__item--active' : ''"
th:href="@{/apis}">
전체
</a>
<!-- Service Categories -->
<div class="menu-section" th:each="service : ${services}">
<a class="menu-title"
th:classappend="${selected == service.id} ? 'active'"
th:href="@{/apis(groupIds=${service.id})}">
[[${service.groupName}]]
</a>
</div>
<a class="service-nav__item" th:each="service : ${services}"
th:classappend="${(#request.getParameter('selected') != null ? #request.getParameter('selected') : (selected != null ? selected : apiSpecInfo.service)) == service.id} ? 'service-nav__item--active' : ''"
th:href="@{/apis(groupIds=${service.id})}">
[[${service.groupName}]]
</a>
</nav>
</aside>
@@ -93,7 +86,9 @@
<div class="api-simple-description" th:if="${apiSpecInfo.apiSimpleDescription != null}">
<p th:text="${apiSpecInfo.apiSimpleDescription}">Simple API description</p>
</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
</div>
</div>
@@ -106,7 +101,8 @@
<div class="org-section-header org-section-header--agreement">
<h3>Request Specification</h3>
</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
</div>
</div>
@@ -126,7 +122,8 @@
<div class="org-section-header org-section-header--agreement">
<h3>Response Specification</h3>
</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
</div>
</div>
@@ -301,7 +298,9 @@
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
const loadScript = (src) => {
@@ -8,8 +8,7 @@
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/api_img.svg}" alt="OPEN API 3D 아이콘"
style="width: 100%; height: 100%; object-fit: contain;" />
<img th:src="@{/img/keyimage/api_img.png}" alt="OPEN API 3D 아이콘" class="service-keyImg" />
</div>
<div class="service-hero__content">
<div class="service-hero__badge">
@@ -129,8 +128,11 @@
apiCards.forEach(function (card) {
// Click handler
card.addEventListener('click', function () {
const href = this.getAttribute('data-href');
let href = this.getAttribute('data-href');
if (href) {
const selectedGroup = groupIdsInput ? groupIdsInput.value : '';
const val = selectedGroup ? selectedGroup : '-1';
href += (href.includes('?') ? '&' : '?') + 'selected=' + encodeURIComponent(val);
window.location.href = href;
}
});
@@ -139,8 +141,11 @@
card.addEventListener('keypress', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const href = this.getAttribute('data-href');
let href = this.getAttribute('data-href');
if (href) {
const selectedGroup = groupIdsInput ? groupIdsInput.value : '';
const val = selectedGroup ? selectedGroup : '-1';
href += (href.includes('?') ? '&' : '?') + 'selected=' + encodeURIComponent(val);
window.location.href = href;
}
}
@@ -1,116 +1,157 @@
<!DOCTYPE html>
<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>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
<h1>공지사항</h1>
</div>
</section>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
<h1>공지사항</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2">
<!-- Hero Section -->
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘"
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>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2">
<!-- Hero Section -->
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘"
style="width: 100%; height: 100%; object-fit: contain;" />
</div>
<h1 class="service-hero__title">공지사항</h1>
<p class="service-hero__desc">DJBank 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해 주시기 바랍니다.</p>
</div>
</div>
</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 class="service-hero__content">
<div class="service-hero__badge">
<span class="service-hero__badge-dot"></span>
<span class="service-hero__badge-text">고객지원</span>
</div>
<h1 class="service-hero__title">공지사항</h1>
<p class="service-hero__desc">DJBank 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해
주시기 바랍니다.</p>
</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>
</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>
</th:block>
</th:block>
</body>
@@ -120,13 +161,31 @@
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) {
var textArea = document.createElement('textarea');
textArea.innerHTML = text;
return textArea.value;
}
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', function () {
var element = document.getElementById('noticeDetail');
if (element) {
element.innerHTML = decodeHTMLEntities(element.innerHTML);
@@ -135,4 +194,4 @@
</script>
</th:block>
</html>
</html>
@@ -16,8 +16,7 @@
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘"
style="width: 100%; height: 100%; object-fit: contain;" />
<img th:src="@{/img/keyimage/notice_img.png}" alt="공지사항 아이콘" class="service-keyImg" />
</div>
<div class="service-hero__content">
<div class="service-hero__badge">
@@ -32,7 +31,7 @@
</section>
<!-- Main Content -->
<div class="service-main">
<div class="container service-main">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('notice')}"></th:block>
@@ -86,8 +85,10 @@
<span class="notice-number"
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--incident" th:if="${notice.noticeType == '3'}">장애</span>
<span class="notice-type-badge notice-type-badge--maintenance" th:if="${notice.noticeType == '2'}">점검</span>
<span class="notice-type-badge notice-type-badge--incident"
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 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">
@@ -115,6 +115,24 @@
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) {
var loginId = form.id.value;
@@ -157,26 +175,8 @@
$(function () {
// CSRF 토큰은 세션 기반 → 세션 만료(server.servlet.session.timeout=10m) 전에
// 메인 페이지로 이동시켜 stale 토큰 제출(403) 방지. 이동은 서버 요청이라 세션 idle도 리셋됨.
// 단, 입력 중에는 이탈 금지 → 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();
// 세션 만료 대응은 head의 익명 keepalive ping(/api/session/ping)이 담당하고,
// 제출 직전 refreshCsrfAndThen()이 CSRF 토큰 재발급 안전망 역할을 한다.
var successMsg = [[${success}]];
console.log("Success message:", successMsg);
@@ -225,7 +225,9 @@
$('#loginLoading').removeClass('active');
customPopups.showAlert('[[#{login.passLengthShort}]]');
} else {
checkDuplicateAndLogin(form);
refreshCsrfAndThen(form, function () {
checkDuplicateAndLogin(form);
});
}
}
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>
<!-- Status Badge -->
<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>
</div>
<!-- App Description & Expected Completion Date -->
@@ -111,13 +111,13 @@
</div>
</div>
<!-- Call Back URL : OAuth2 3-legged 전용. 현재 미사용으로 주석 처리 (추후 OAuth2 도입 시 복원)
<!--/* Call Back URL : OAuth2 3-legged 전용. 현재 미사용으로 주석 처리 (추후 OAuth2 도입 시 복원)
<div class="s1-field">
<label class="s1-label">Call Back URL</label>
<input type="text" id="callbackUrl" name="callbackUrl" th:field="*{callbackUrl}" class="s1-input"
placeholder="URL을 입력해 주세요.">
</div>
-->
*/-->
<!-- 화이트 리스트 -->
<div class="s1-field">
@@ -54,6 +54,9 @@
대기중
</span>
</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>
<!-- App Description -->
<p class="dt-app-desc"
@@ -200,12 +203,19 @@
</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 -->
<div class="dt-actions">
<!-- Cancel Request Button (danger 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
(appRequest.approval.approvalStatus.toString() == 'PENDING' or appRequest.approval.approvalStatus.toString() == 'REQUESTED')}"
th:if="${appRequest.approval == null or (appRequest.approval.approvalStatus != null and
(appRequest.approval.approvalStatus.toString() == 'PENDING' or appRequest.approval.approvalStatus.toString() == 'REQUESTED'))}"
th:data-request-id="${appRequest.id}" onclick="cancelRequestById(this)">
신청 취소
</button>
@@ -51,16 +51,6 @@
<!-- Credential Info Section Card -->
<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 -->
<div class="dt-row">
<div class="dt-col">
@@ -48,7 +48,7 @@
<!-- Step 1 -->
<div class="signup-step">
<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">
<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"
@@ -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"
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
</svg> -->
</svg> */-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-person-add" viewBox="0 0 16 16">
<path
@@ -83,7 +83,7 @@
<!-- Step 2 -->
<div class="signup-step">
<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">
<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"
@@ -95,7 +95,7 @@
stroke-linejoin="round" />
<path d="M19 16L21 18L25 14" stroke="#0049B4" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
</svg> -->
</svg> */-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-journal-check" viewBox="0 0 16 16">
<path fill-rule="evenodd"
@@ -115,14 +115,14 @@
<!-- Step 3 -->
<div class="signup-step">
<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">
<rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="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 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"
class="bi bi-code" viewBox="0 0 16 16">
<path
@@ -138,13 +138,13 @@
<!-- Step 4 -->
<div class="signup-step">
<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">
<path d="M21 8H24V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M8 8H11V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
</svg> -->
</svg> */-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-display" viewBox="0 0 16 16">
<path
@@ -160,14 +160,14 @@
<!-- Step 5 -->
<div class="signup-step">
<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">-->
<!-- <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" />-->
<!-- <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 11H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />*/-->
<!--/* <path d="M12 15L14.5 17.5L20 12" stroke="#0049B4" stroke-width="1.6"*/-->
<!-- stroke-linecap="round" stroke-linejoin="round" />-->
<!-- </svg>-->
<!--/* </svg>*/-->
<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">
<path
@@ -183,7 +183,7 @@
<!-- Step 6 -->
<div class="signup-step">
<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">
<rect x="7" y="9" width="18" height="16" rx="2" stroke="#0049B4" stroke-width="1.6"
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="M12 17H14" 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"
class="bi bi-airplane" viewBox="0 0 16 16">
<path
@@ -92,7 +92,7 @@
<span class="oauth2-2legged__prereq-num">3</span>
<div class="oauth2-2legged__prereq-body">
<h3 class="oauth2-2legged__prereq-title">Scope 확인</h3>
<p>호출하려는 API에 필요한 scope</p>
<p>scope 는 고정값 "api" 사용</p>
<p>권한이 부여됐는지 확인합니다.</p>
</div>
</article>
@@ -136,7 +136,7 @@
<text x="560" y="102" text-anchor="middle" font-size="12" font-weight="700"
fill="#0049b4">① POST /dj/oauth/token</text>
<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"
marker-end="url(#o2leg-arrow-primary)" />
@@ -145,11 +145,11 @@
<line x1="880" y1="170" x2="240" y2="170" stroke="#64748B" stroke-width="2"
marker-end="url(#o2leg-arrow-gray)" />
<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 x="560" y="216" text-anchor="middle" font-size="12" font-weight="700"
fill="#0049b4">③ GET /api/v1/... · Authorization: Bearer &lt;access_token&gt;</text>
fill="#0049b4">③ GET /api/v1/... · X-AUTH-TOKEN: Bearer &lt;access_token&gt;</text>
<line x1="240" y1="226" x2="880" y2="226" stroke="#0049b4" stroke-width="2"
marker-end="url(#o2leg-arrow-primary)" />
@@ -210,9 +210,9 @@
<tr>
<td><code>scope</code></td>
<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>고정값 "api"</td>
</tr>
</tbody>
</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">'client_id=YOUR_CLIENT_ID'</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>
</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">"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">"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>
}
@@ -284,7 +284,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
<tr>
<td><code class="oauth2-2legged__field-name">scope</code></td>
<td>string</td>
<td>실제 부여된 권한 범위</td>
<td>부여된 권한 범위 (api)</td>
</tr>
<tr>
<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">
<span class="oauth2-2legged__eyebrow">STEP 3</span>
<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__panel">
<h3 class="oauth2-2legged__panel-title">필수 헤더</h3>
<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>
</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>만료 임박 시 재발급 후 교체 (예: TTL의 80% 시점)</li>
<li>매 호출마다 토큰을 새로 발급하지 마세요</li>
<li>권한이 다른 API 는 scope 별로 토큰을 분리 발급</li>
</ul>
</ul>
</div>
<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>
curl -X <span class="o2leg-y">GET</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>
<span class="o2leg-g"># 응답</span>
@@ -1,197 +1,224 @@
<!doctype html>
<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>
<th:block layout:fragment="contentFragment">
<section class="oauth2-2legged">
<th:block layout:fragment="contentFragment">
<section class="oauth2-2legged">
<!-- Section 1: Hero -->
<header class="oauth2-2legged__hero">
<div class="oauth2-2legged__hero-inner">
<div class="oauth2-2legged__hero-body">
<span class="oauth2-2legged__hero-eyebrow">
<span class="oauth2-2legged__hero-eyebrow-dot"></span>
개발 가이드 · Webhook · HMAC-SHA256
</span>
<h1 class="oauth2-2legged__hero-title">웹훅 개발가이드</h1>
<p class="oauth2-2legged__hero-lead">DJBank가 발송하는 Webhook 요청의 진위를 확인하기 위한</p>
<p class="oauth2-2legged__hero-lead">HMAC-SHA256 서명 검증 방법을 단계별로 설명합니다.</p>
<!-- Section 1: Hero -->
<header class="oauth2-2legged__hero">
<div class="oauth2-2legged__hero-inner">
<div class="oauth2-2legged__hero-body">
<span class="oauth2-2legged__hero-eyebrow">
<span class="oauth2-2legged__hero-eyebrow-dot"></span>
개발 가이드 · Webhook · HMAC-SHA256
</span>
<h1 class="oauth2-2legged__hero-title">웹훅 개발가이드</h1>
<p class="oauth2-2legged__hero-lead">DJBank가 발송하는 Webhook 요청의 진위를 확인하기 위한 HMAC-SHA256 서명 검증 방법을
단계별로 설명합니다.</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">X-Webhook-Signature</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>
</header>
<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"/>
<div class="container service-main">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhookGuide')}"></th:block>
<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>
<div class="service-content">
<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>
<!-- Section 2: 개요 -->
<section class="oauth2-2legged__prereq" aria-labelledby="whsig-prereq-title">
<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>[마이페이지 &gt; Webhook 관리]에서 발급된</p>
<p>Secret Key를 서버에 안전하게 보관.</p>
</div>
</article>
<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>
<article class="oauth2-2legged__prereq-card">
<span class="oauth2-2legged__prereq-num">2</span>
<div class="oauth2-2legged__prereq-body">
<h3 class="oauth2-2legged__prereq-title">원문(raw body) 보존</h3>
<p>수신 즉시 본문을 파싱/재직렬화하지 말고</p>
<p>바이트 원문 그대로 서명 계산에 사용.</p>
</div>
</article>
<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>
<article class="oauth2-2legged__prereq-card">
<span class="oauth2-2legged__prereq-num">3</span>
<div class="oauth2-2legged__prereq-body">
<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>
</svg>
</div>
</div>
</header>
<!-- Section 3: 전체 서명·검증 시퀀스 -->
<section class="oauth2-2legged__sequence" aria-labelledby="whsig-seq-title">
<span class="oauth2-2legged__eyebrow">SEQUENCE</span>
<h2 class="oauth2-2legged__h2" id="whsig-seq-title">전체 서명·검증 시퀀스</h2>
<div class="container service-main">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhookGuide')}"></th:block>
<div class="oauth2-2legged__sequence-diagram">
<svg viewBox="0 0 1120 300" xmlns="http://www.w3.org/2000/svg" role="img"
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: 개요 -->
<section class="oauth2-2legged__prereq" aria-labelledby="whsig-prereq-title">
<span class="oauth2-2legged__eyebrow">OVERVIEW</span>
<h2 class="oauth2-2legged__h2" id="whsig-prereq-title">서명 검증이 필요한 이유</h2>
<text x="240" y="104" text-anchor="middle" font-size="12" font-weight="700"
fill="#64748B">① 이벤트 발생 (점검·지연·장애)</text>
<text x="240" y="124" text-anchor="middle" font-size="12" font-weight="700"
fill="#64748B">② signature = HMAC-SHA256(secret, body)</text>
<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>[마이페이지 &gt; Webhook 관리]에서 발급된</p>
<p>Secret Key를 서버에 안전하게 보관.</p>
</div>
</article>
<text x="560" y="156" text-anchor="middle" font-size="12" font-weight="700"
fill="#0049b4">③ POST body · X-Webhook-Signature: sha256=&lt;hex&gt;</text>
<line x1="240" y1="166" x2="880" y2="166" stroke="#0049b4" stroke-width="2"
marker-end="url(#whsig-arrow-primary)" />
<article class="oauth2-2legged__prereq-card">
<span class="oauth2-2legged__prereq-num">2</span>
<div class="oauth2-2legged__prereq-body">
<h3 class="oauth2-2legged__prereq-title">원문(raw body) 보존</h3>
<p>수신 즉시 본문을 파싱/재직렬화하지 말고</p>
<p>바이트 원문 그대로 서명 계산에 사용.</p>
</div>
</article>
<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>
<article class="oauth2-2legged__prereq-card">
<span class="oauth2-2legged__prereq-num">3</span>
<div class="oauth2-2legged__prereq-body">
<h3 class="oauth2-2legged__prereq-title">HMAC 재계산·비교</h3>
<p>동일 Secret으로 HMAC-SHA256을 재계산해</p>
<p>헤더 서명과 상수 시간으로 비교.</p>
</div>
</article>
</div>
</section>
<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 3: 전체 서명·검증 시퀀스 -->
<section class="oauth2-2legged__sequence" aria-labelledby="whsig-seq-title">
<span class="oauth2-2legged__eyebrow">SEQUENCE</span>
<h2 class="oauth2-2legged__h2" id="whsig-seq-title">전체 서명·검증 시퀀스</h2>
<!-- 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__sequence-diagram">
<svg viewBox="0 0 1120 300" xmlns="http://www.w3.org/2000/svg" role="img" 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="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>
<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>
<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=&lt;hex&gt;</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>
<text x="240" y="124" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">② signature = HMAC-SHA256(secret, body)</text>
<text x="560" y="156" text-anchor="middle" font-size="12" font-weight="700" fill="#0049b4">③ POST body · X-Webhook-Signature: sha256=&lt;hex&gt;</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=&lt;hex&gt;</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">{
<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">"eventId"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-a567-0e02b2c3d479"</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>
</div>
</div>
</section>
</div>
</div>
</section>
<!-- Section 5: STEP 2 — 서명 검증 알고리즘 -->
<section class="oauth2-2legged__step" aria-labelledby="whsig-step2-title">
<span class="oauth2-2legged__eyebrow">STEP 2</span>
<h2 class="oauth2-2legged__h2" id="whsig-step2-title">서명 검증 알고리즘</h2>
<p class="oauth2-2legged__desc">수신한 본문 원문과 발급된 Secret으로 서명을 재계산해 헤더 값과 비교합니다.</p>
<!-- Section 5: STEP 2 — 서명 검증 알고리즘 -->
<section class="oauth2-2legged__step" aria-labelledby="whsig-step2-title">
<span class="oauth2-2legged__eyebrow">STEP 2</span>
<h2 class="oauth2-2legged__h2" id="whsig-step2-title">서명 검증 알고리즘</h2>
<p class="oauth2-2legged__desc">수신한 본문 원문과 발급된 Secret으로 서명을 재계산해 헤더 값과 비교합니다.</p>
<div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Verification Steps</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 1) 헤더에서 서명 추출</span>
<div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Verification Steps</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>
<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>
valid = constantTimeEquals(received, expected)</pre>
</div>
</div>
<div class="oauth2-2legged__panel">
<h3 class="oauth2-2legged__panel-title">검증 규칙</h3>
<ul class="oauth2-2legged__tips">
<li>알고리즘: <code>HmacSHA256</code>, 키: Secret(UTF-8 bytes)</li>
<li>메시지: 수신 <strong>본문 원문</strong>(UTF-8 bytes)</li>
<li>출력: <strong>소문자 hex</strong>, 헤더는 <code>sha256=</code> 접두 포함</li>
<li>비교: 타이밍 공격 방지를 위해 <strong>상수 시간</strong> 비교</li>
<li>불일치 시 요청을 폐기하고 2xx 이외로 응답</li>
</ul>
<h4 class="oauth2-2legged__panel-subtitle">재전송(replay) 방어</h4>
<ul class="oauth2-2legged__tips">
<li><code>X-Webhook-Timestamp</code>가 허용 오차(예: 5분) 밖이면 거부</li>
<li>동일 <code>eventId</code> 중복 수신은 멱등 처리</li>
</ul>
</div>
</div>
</section>
<div class="oauth2-2legged__panel">
<h3 class="oauth2-2legged__panel-title">검증 규칙</h3>
<ul class="oauth2-2legged__tips">
<li>알고리즘: <code>HmacSHA256</code>, 키: Secret(UTF-8 bytes)</li>
<li>메시지: 수신 <strong>본문 원문</strong>(UTF-8 bytes)</li>
<li>출력: <strong>소문자 hex</strong>, 헤더는 <code>sha256=</code> 접두 포함</li>
<li>비교: 타이밍 공격 방지를 위해 <strong>상수 시간</strong> 비교</li>
<li>불일치 시 요청을 폐기하고 2xx 이외로 응답</li>
</ul>
<h4 class="oauth2-2legged__panel-subtitle">재전송(replay) 방어</h4>
<ul class="oauth2-2legged__tips">
<li><code>X-Webhook-Timestamp</code>가 허용 오차(예: 5분) 밖이면 거부</li>
<li>동일 <code>eventId</code> 중복 수신은 멱등 처리</li>
</ul>
</div>
</div>
</section>
<!-- Section 6: STEP 3 — 언어별 예제 -->
<section class="oauth2-2legged__step" aria-labelledby="whsig-step3-title">
<span class="oauth2-2legged__eyebrow">STEP 3</span>
<h2 class="oauth2-2legged__h2" id="whsig-step3-title">언어별 서명 검증 예제</h2>
<p class="oauth2-2legged__desc">프레임워크에서 반드시 <strong>원문 바디</strong>에 접근할 수 있어야 합니다.</p>
<!-- Section 6: STEP 3 — 언어별 예제 -->
<section class="oauth2-2legged__step" aria-labelledby="whsig-step3-title">
<span class="oauth2-2legged__eyebrow">STEP 3</span>
<h2 class="oauth2-2legged__h2" id="whsig-step3-title">언어별 서명 검증 예제</h2>
<p class="oauth2-2legged__desc">프레임워크에서 반드시 <strong>원문 바디</strong>에 접근할 수 있어야 합니다.</p>
<div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__code-panel">
<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>);
<div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__code-panel">
<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>);
<span class="o2leg-cm">// rawBody: express.raw() 등으로 확보한 원문 Buffer</span>
<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 &amp;&amp;
crypto.<span class="o2leg-y">timingSafeEqual</span>(a, b);
}</pre>
</div>
</div>
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Python (Flask)</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">import</span> hmac, hashlib
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Python (Flask)</span>
<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-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>()
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>
</div>
</div>
</div>
</div>
<div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Java</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">import</span> javax.crypto.Mac;
<div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Java</span>
<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> java.nio.charset.StandardCharsets;
<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),
header.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8));
}</pre>
</div>
</div>
<div class="oauth2-2legged__panel">
<h3 class="oauth2-2legged__panel-title">이벤트 코드 (X-Webhook-Event)</h3>
<table class="oauth2-2legged__table">
<thead>
<tr>
<th>CODE</th>
<th>의미</th>
</tr>
</thead>
<tbody>
<tr><td><code>CONTROL_START</code></td><td>점검 시작</td></tr>
<tr><td><code>CONTROL_END</code></td><td>점검 종료</td></tr>
<tr><td><code>DELAY_START</code></td><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>
<div class="oauth2-2legged__panel">
<h3 class="oauth2-2legged__panel-title">이벤트 코드 (X-Webhook-Event)</h3>
<table class="oauth2-2legged__table">
<thead>
<tr>
<th>CODE</th>
<th>의미</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>CONTROL_START</code></td>
<td>점검 시작</td>
</tr>
<tr>
<td><code>CONTROL_END</code></td>
<td>점검 종료</td>
</tr>
<tr>
<td><code>DELAY_START</code></td>
<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 class="oauth2-2legged__step" aria-labelledby="whsig-step4-title">
<span class="oauth2-2legged__eyebrow">STEP 4</span>
<h2 class="oauth2-2legged__h2" id="whsig-step4-title">응답(리턴) 반환 규칙</h2>
<p class="oauth2-2legged__desc">수신 서버가 반환하는 HTTP 상태 코드에 따라 DJBank의 성공 판정과 재시도가 결정됩니다.</p>
<!-- Section 6.5: STEP 4 — 응답 반환 규칙 -->
<section class="oauth2-2legged__step" aria-labelledby="whsig-step4-title">
<span class="oauth2-2legged__eyebrow">STEP 4</span>
<h2 class="oauth2-2legged__h2" id="whsig-step4-title">응답(리턴) 반환 규칙</h2>
<p class="oauth2-2legged__desc">수신 서버가 반환하는 HTTP 상태 코드에 따라 DJBank의 성공 판정과 재시도가 결정됩니다.</p>
<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>반환</th>
<th>상황</th>
<th>DJBank 처리</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">2xx</span></td>
<td>검증 성공 + 정상 접수</td>
<td><strong>발송 성공</strong> 기록. 재시도 없음</td>
</tr>
<tr>
<td><code>400</code></td>
<td>필수 헤더 누락</td>
<td>실패 기록 + 재시도</td>
</tr>
<tr>
<td><code>401</code></td>
<td>서명 불일치 / timestamp 만료</td>
<td>실패 기록 + 재시도</td>
</tr>
<tr>
<td><code>5xx</code> · 타임아웃</td>
<td>수신 서버 일시 장애</td>
<td>실패 기록 + 재시도</td>
</tr>
</tbody>
</table>
<p class="oauth2-2legged__warning">⚠ 2xx 이외 응답과 네트워크 오류는 <strong>일정 간격을 두고 재시도</strong>됩니다(기본 3회). 재시도로 인한 중복 수신은 <code>eventId</code> 멱등 처리로 방어하세요.</p>
<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>반환</th>
<th>상황</th>
<th>DJBank 처리</th>
</tr>
</thead>
<tbody>
<tr>
<td><span
class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">2xx</span>
</td>
<td>검증 성공 + 정상 접수</td>
<td><strong>발송 성공</strong> 기록. 재시도 없음</td>
</tr>
<tr>
<td><code>400</code></td>
<td>필수 헤더 누락</td>
<td>실패 기록</td>
</tr>
<tr>
<td><code>401</code></td>
<td>서명 불일치 / timestamp 만료</td>
<td>실패 기록</td>
</tr>
<tr>
<td><code>5xx</code> · 타임아웃</td>
<td>수신 서버 일시 장애</td>
<td>실패 기록 + 재시도</td>
</tr>
</tbody>
</table>
<p class="oauth2-2legged__warning">⚠ 5xx 응답·타임아웃과 네트워크 오류는 <strong>일정 간격을 두고
재시도</strong>됩니다(기본 3회). 재시도로 인한 중복 수신은 <code>eventId</code> 멱등 처리로 방어하세요.</p>
>>>>>>> 9981459691c836bfd33b6b81a8e6aa22ca12446a
<h4 class="oauth2-2legged__panel-subtitle">응답 가이드</h4>
<ul class="oauth2-2legged__tips">
<li>검증 통과 시 <strong>즉시 200 OK</strong> 반환 — 무거운 후속 처리는 비동기로 분리</li>
<li>응답 본문 규격은 자유(발송 로그에 기록만 됨) — 간단한 JSON 권장</li>
<li>서명 검증 실패는 <code>401</code>, 필수 헤더 누락은 <code>400</code> 반환 권장</li>
<li>동일 <code>eventId</code> 재수신 시 재처리 없이 200 반환(멱등)</li>
</ul>
</div>
<h4 class="oauth2-2legged__panel-subtitle">응답 가이드</h4>
<ul class="oauth2-2legged__tips">
<li>검증 통과 시 <strong>즉시 200 OK</strong> 반환 — 무거운 후속 처리는 비동기로 분리</li>
<li>응답 본문 규격은 자유(발송 로그에 기록만 됨) — 간단한 JSON 권장</li>
<li>서명 검증 실패는 <code>401</code>, 필수 헤더 누락은 <code>400</code> 반환 권장</li>
<li>동일 <code>eventId</code> 재수신 시 재처리 없이 200 반환(멱등)</li>
</ul>
</div>
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag oauth2-2legged__code-tag--ok">200 OK · JSON (권장)</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 정상 접수</span>
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag oauth2-2legged__code-tag--ok">200 OK · JSON
(권장)</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 정상 접수</span>
HTTP/1.1 <span class="o2leg-g">200 OK</span>
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">"message"</span>: <span class="o2leg-y">"필수 헤더가 누락되었습니다."</span>
}</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>
</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>
</section>
</th:block>
</th:block>
</body>
<th:block layout:fragment="contentScript">
</th:block>
</html>
</html>
@@ -75,6 +75,7 @@
color: #212529;
text-align: center;
line-height: 1.6;
white-space: pre-line;
}
.error-buttons {
@@ -246,7 +247,7 @@
<div class="error-container">
<!-- 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="에러">
</div>
@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<body>
<!--
<!--/*
API 선택 공용 모듈 (figma s2 디자인: 카테고리 캐러셀 탭 + 카드그리드 + 플로팅 카트 + 선택목록 모달)
사용처: 앱(API Key) 신청/수정 step2, Webhook 신청/수정 step2
@@ -25,7 +25,7 @@
- 추가 hidden 필드는 호출 페이지에서 form="apiSelectorForm" 속성으로 주입(예: apikey 수정 clientId).
- API 목록: GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX.
- 스타일: design s2-* (_apikey-register.scss step2 재작업분) 재사용.
-->
*/-->
<th:block th:fragment="apiSelector(apiServices, selectedApis, formAction, saveAction)">
<!-- Category Carousel Tab Container -->
@@ -106,7 +106,7 @@
<p class="pop_text l_text2">신청사유</p>
<div class="pop_textbox">
<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 class="pop_btnbox">
<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>
<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">
<title th:text="#{title.html}">개발자 포털</title>
@@ -27,6 +28,23 @@
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
<meta name="_csrf" th:content="${_csrf != null ? _csrf.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="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">
@@ -100,9 +118,9 @@
<script th:src="@{/plugins/summernote/summernote-cleaner.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 를 로드한다.
prod/stage 에는 DevTools 자체가 없으므로(developmentOnly) 개발 프로파일에서만 주입한다. -->
prod/stage 에는 DevTools 자체가 없으므로(developmentOnly) 개발 프로파일에서만 주입한다. */-->
<script th:if="${@environment.acceptsProfiles('local_rinjaemac','local')}"
th:src="|//${#request.serverName}:35729/livereload.js|"></script>
</head>
@@ -21,12 +21,12 @@
<!-- Desktop Header Layout -->
<div class="desktop-header">
<div class="header-left">
<!-- <div class="logo-wrapper">-->
<!-- <a th:href="@{/}" class="logo-link">-->
<!-- <img src="/img/logo/logo-djb.png" alt="DJBank" class="logo" width="114" height="32">-->
<!-- </a>-->
<!-- <a th:href="@{/}" class="logo-text">API Portal</a>-->
<!-- </div>-->
<!--/* <div class="logo-wrapper">*/-->
<!--/* <a th:href="@{/}" class="logo-link">*/-->
<!--/* <img src="/img/logo/logo-djb.png" alt="DJBank" class="logo" width="114" height="32">*/-->
<!--/* </a>*/-->
<!--/* <a th:href="@{/}" class="logo-text">API Portal</a>*/-->
<!--/* </div>*/-->
<div >
<div class="logo">
<a th:href="@{/}" class="logo-wrapper">
@@ -63,15 +63,15 @@
<div class="header-right">
<!-- 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">-->
<!-- <img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">로그인</a>-->
<!-- </div>-->
<!--/* <a th:href="@{/login}" class="login-btn login-btn-box">*/-->
<!--/* <img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">로그인</a>*/-->
<!--/* </div>*/-->
<div class="auth-group" sec:authorize="isAnonymous()">
<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>
<!-- Logout State (Authenticated) -->
@@ -93,7 +93,7 @@
</li>
<li sec:authorize="hasRole('ROLE_APP')">
<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>
</li>
<li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li>
@@ -250,7 +250,7 @@
<ul class="drawer-submenu">
<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="@{/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><a th:href="@{/mypage}">내 정보 관리</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 LOGOUT_URL = /*[[@{/actionLogout.do}]]*/ '/actionLogout.do';
var timeoutMinutes = /*[[${sessionTimeoutMinutes}]]*/ 15;
var timeoutMinutes = /*[[${sessionTimeoutMinutes}]]*/ 10;
var remainingSeconds = timeoutMinutes * 60;
var WARNING_SECONDS = 60; // 만료 60초 전 연장 확인 모달
var POLL_INTERVAL_MS = 30000; // 서버 잔여시간 동기화 주기
@@ -62,7 +62,7 @@
<a th:href="@{/webhook}"
th:classappend="${activeMenu == 'webhook'} ? 'service-nav__item--active' : ''"
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}"
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>
+2 -1
View File
@@ -14,7 +14,8 @@
</container-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>
<persistent-store-type>replicated_if_clustered</persistent-store-type>
</session-descriptor>