merge 충돌수정

This commit is contained in:
hong
2026-07-24 10:24:07 +09:00
33 changed files with 4888 additions and 645 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); ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
String url = httpServletRequest.getHeader("original-url"); String url = httpServletRequest.getHeader("original-url");
// 감사 로그: 요청 1건당 REQ/RES 두 줄을 같은 auditId 로 남긴다 (전용 파일, 1년 보관)
String auditId = ApiTesterAuditLogger.newAuditId();
long auditStart = System.currentTimeMillis();
String auditType = "-";
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지) // original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
if (url == null || url.trim().isEmpty()) { if (url == null || url.trim().isEmpty()) {
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, null, "-", false, null);
ApiTesterAuditLogger.logResult(auditId, HttpServletResponse.SC_BAD_REQUEST, "BAD_REQUEST",
System.currentTimeMillis() - auditStart);
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}"); writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}");
return; return;
} }
@@ -84,16 +92,15 @@ public class ApiTesterFilter implements Filter {
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH) boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|| url.contains(gatewayProperty.tokenPath()); || url.contains(gatewayProperty.tokenPath());
// 본문은 한 번만 읽어 프록시 forward 와 감사 로그에 함께 사용 (GET 이면 빈 문자열)
String requestBody = readBody(httpServletRequest);
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url, gatewayMode.name(), tokenRequest, requestBody);
if (tokenRequest) { if (tokenRequest) {
StringBuilder sb = new StringBuilder(); String body = requestBody;
BufferedReader reader = httpServletRequest.getReader();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
String body = sb.toString();
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) { if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
auditType = "TOKEN_MOCK";
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지) // PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
Map<String, String> params = new HashMap<>(); Map<String, String> params = new HashMap<>();
String[] pairs = body.split("&"); String[] pairs = body.split("&");
@@ -106,7 +113,7 @@ public class ApiTesterFilter implements Filter {
String scope = params.getOrDefault("scope", "default"); String scope = params.getOrDefault("scope", "default");
String token = "{\n" + String token = "{\n" +
" \"access_token\": \"djbank_gw_sample_token\",\n" + " \"access_token\": \"" + escapeJson(gatewayProperty.mockAccessToken()) + "\",\n" +
" \"token_type\": \"bearer\",\n" + " \"token_type\": \"bearer\",\n" +
" \"expires_in\": 86400,\n" + " \"expires_in\": 86400,\n" +
" \"scope\": \""+scope +"\",\n" + " \"scope\": \""+scope +"\",\n" +
@@ -117,6 +124,7 @@ public class ApiTesterFilter implements Filter {
response.getWriter().println(token); response.getWriter().println(token);
} else { } else {
// GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만) // GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만)
auditType = "TOKEN_GW";
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class); APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
Map<String, String> headers = new HashMap<>(); Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", "application/x-www-form-urlencoded"); headers.put("Content-Type", "application/x-www-form-urlencoded");
@@ -133,6 +141,7 @@ public class ApiTesterFilter implements Filter {
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지) // URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
if (apiSpecInfoDto == null) { if (apiSpecInfoDto == null) {
auditType = "SPEC_NOT_FOUND";
writeJson(response, HttpServletResponse.SC_NOT_FOUND, writeJson(response, HttpServletResponse.SC_NOT_FOUND,
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}"); "{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
return; return;
@@ -142,6 +151,7 @@ public class ApiTesterFilter implements Filter {
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음) // sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
if (responseType == null || responseType.equalsIgnoreCase("sample")) { if (responseType == null || responseType.equalsIgnoreCase("sample")) {
auditType = "SAMPLE";
response.setContentType("application/json"); response.setContentType("application/json");
response.getWriter().println(apiSpecInfoDto.getSampleResponse()); response.getWriter().println(apiSpecInfoDto.getSampleResponse());
return; return;
@@ -151,6 +161,7 @@ public class ApiTesterFilter implements Filter {
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path) // - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
// - mock : ApiSpecInfo.mockUrl (기존 동작) // - mock : ApiSpecInfo.mockUrl (기존 동작)
boolean gw = "gw".equalsIgnoreCase(responseType); boolean gw = "gw".equalsIgnoreCase(responseType);
auditType = gw ? "GW" : "MOCK";
Map<String, String> headers = new HashMap<>(); Map<String, String> headers = new HashMap<>();
Enumeration<String> headerNames = httpServletRequest.getHeaderNames(); Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
@@ -160,6 +171,10 @@ public class ApiTesterFilter implements Filter {
} }
headers.remove("original-url"); headers.remove("original-url");
headers.remove("original-api-id"); headers.remove("original-api-id");
// readBody()가 개행을 제거해 원본 Content-Length와 실제 전송 바이트가 달라질 수 있고,
// WebLogic HTTP 클라이언트는 이 불일치를 IOException으로 처리하므로 length 계열 헤더는
// 전달하지 않는다(HttpURLConnection이 실제 바이트 수로 재설정).
headers.keySet().removeIf(k -> "content-length".equalsIgnoreCase(k) || "transfer-encoding".equalsIgnoreCase(k));
String targetUri; String targetUri;
Map<String, String[]> paramMap; Map<String, String[]> paramMap;
@@ -179,7 +194,7 @@ public class ApiTesterFilter implements Filter {
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class); APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
String responseStr; String responseStr;
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) { if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
responseStr = apiSender.requestPost(targetUri, headers, paramMap, readBody(httpServletRequest)); responseStr = apiSender.requestPost(targetUri, headers, paramMap, requestBody);
} else { } else {
responseStr = apiSender.requestGet(targetUri, headers, paramMap); responseStr = apiSender.requestGet(targetUri, headers, paramMap);
} }
@@ -202,6 +217,9 @@ public class ApiTesterFilter implements Filter {
logger.error("테스트베드 프록시 처리 오류", e); logger.error("테스트베드 프록시 처리 오류", e);
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}"); "{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} finally {
ApiTesterAuditLogger.logResult(auditId, ((HttpServletResponse) response).getStatus(), auditType,
System.currentTimeMillis() - auditStart);
} }
} }
@@ -5,6 +5,7 @@ import com.eactive.apim.portal.apps.session.service.UserSessionService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -25,6 +26,8 @@ import java.util.Optional;
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li> * <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li> * <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li> * <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
* <li>GET /api/session/ping - 익명 세션 keepalive (로그인/회원가입 페이지)</li>
* <li>GET /api/session/csrf - 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망)</li>
* </ul> * </ul>
*/ */
@Slf4j @Slf4j
@@ -109,6 +112,37 @@ public class SessionApiController {
return ResponseEntity.ok(result); return ResponseEntity.ok(result);
} }
/**
* 익명(비로그인) 페이지용 세션 keepalive ping.
* 요청이 기존 세션에 접근하는 것만으로 컨테이너의 세션 비활성 타이머가 리셋되어
* 익명 세션(세션 저장 CSRF 토큰, 회원가입 본인인증 상태 포함)이 유지된다.
* 세션이 없으면 새로 만들지 않는다.
*/
@GetMapping("/ping")
public ResponseEntity<Void> ping(HttpServletRequest request) {
request.getSession(false);
return ResponseEntity.noContent().build();
}
/**
* 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망).
* 세션 만료로 토큰이 사라진 경우 CsrfFilter가 새 토큰을 생성하고,
* 이 핸들러가 토큰 값을 읽는 시점에 새 세션에 저장된다(LazyCsrfTokenRepository).
* 회원가입 절차는 세션에 본인인증 상태를 들고 있어 토큰 재발급만으로는 복구가 안 되므로
* 로그인 페이지 안전망으로만 사용한다.
*/
@GetMapping("/csrf")
public ResponseEntity<Map<String, String>> csrfToken(HttpServletRequest request) {
CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
Map<String, String> result = new HashMap<>();
if (token != null) {
result.put("headerName", token.getHeaderName());
result.put("parameterName", token.getParameterName());
result.put("token", token.getToken());
}
return ResponseEntity.ok(result);
}
/** /**
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환) * IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
* 예: 192.168.240.178 → 192.168.***.178 * 예: 192.168.240.178 → 192.168.***.178
@@ -20,8 +20,9 @@ import java.util.Optional;
public class UserSessionService { public class UserSessionService {
private static final String PROPERTY_GROUP = "Portal"; private static final String PROPERTY_GROUP = "Portal";
private static final String PROPERTY_NAME = "session.timeout.minutes";
private static final String DEFAULT_TIMEOUT_MINUTES = "15"; /** 세션 타임아웃(분) 고정값. application.yml(timeout: 10m)·weblogic.xml(timeout-secs 600)과 동일하게 유지한다. */
public static final int SESSION_TIMEOUT_MINUTES = 10;
/** 세션 유지(타임아웃 무시) 기능 활성화 여부 프로퍼티 (true/false). 비운영 전용 — prod 가드는 상위(GlobalControllerAdvice)에서 적용 */ /** 세션 유지(타임아웃 무시) 기능 활성화 여부 프로퍼티 (true/false). 비운영 전용 — prod 가드는 상위(GlobalControllerAdvice)에서 적용 */
private static final String KEEPALIVE_PROPERTY_NAME = "session.keepalive.enabled"; private static final String KEEPALIVE_PROPERTY_NAME = "session.keepalive.enabled";
@@ -120,21 +121,10 @@ public class UserSessionService {
} }
/** /**
* DB(PortalProperty)에서 세션 타임아웃 값 조회 (분) * 세션 타임아웃(분). {@value #SESSION_TIMEOUT_MINUTES}분 고정 (DB property 관리 폐지).
*/ */
public int getSessionTimeoutMinutes() { public int getSessionTimeoutMinutes() {
String value = portalPropertyService.getOrCreateProperty( return SESSION_TIMEOUT_MINUTES;
PROPERTY_GROUP,
PROPERTY_NAME,
DEFAULT_TIMEOUT_MINUTES,
"세션 타임아웃 시간 (분)"
);
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
log.warn("세션 타임아웃 값 파싱 실패: {}, 기본값 {}분 사용", value, DEFAULT_TIMEOUT_MINUTES);
return Integer.parseInt(DEFAULT_TIMEOUT_MINUTES);
}
} }
/** /**
@@ -62,7 +62,7 @@ public class GlobalControllerAdvice {
} }
/** /**
* 화면 세션 타이머 기준이 되는 타임아웃(분). PortalProperty(Portal/session.timeout.minutes)에서 조회. * 화면 세션 타이머 기준이 되는 타임아웃(분). 10분 고정 (UserSessionService.SESSION_TIMEOUT_MINUTES).
*/ */
@ModelAttribute("sessionTimeoutMinutes") @ModelAttribute("sessionTimeoutMinutes")
public int sessionTimeoutMinutes() { public int sessionTimeoutMinutes() {
@@ -122,8 +122,8 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername, userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
clientIp, request.getHeader("User-Agent")); clientIp, request.getHeader("User-Agent"));
// 물리 세션 타임아웃을 DB property(Portal/session.timeout.minutes)와 일치시킴. // 물리 세션 타임아웃 10분 고정. yml(timeout: 10m)·weblogic.xml(timeout-secs 600)과 동일 값이지만
// yml/weblogic.xml 기본값을 이 세션에 대해 override → 물리=논리 단일화(CSRF 수명 포함). // 컨테이너 설정(콘솔 override 등)과 무관하게 보장하기 위해 명시 적용 → 물리=논리 단일화(CSRF 수명 포함).
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60); session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
// 로그인 성공 시 세션 정보 로깅 // 로그인 성공 시 세션 정보 로깅
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.config;
import com.eactive.apim.portal.common.util.HttpRequestUtil; import com.eactive.apim.portal.common.util.HttpRequestUtil;
import com.eactive.apim.portal.common.util.SecurityUtil; import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import org.aspectj.lang.JoinPoint; import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before; import org.aspectj.lang.annotation.Before;
@@ -32,7 +33,7 @@ public class PortalConfigLog {
String query = request.getQueryString(); String query = request.getQueryString();
String ip = HttpRequestUtil.getClientIpAddress(request); String ip = HttpRequestUtil.getClientIpAddress(request);
String user = SecurityUtil.getCurrentLoginId(); String user = StringMaskingUtil.maskEmail(SecurityUtil.getCurrentLoginId());
String message = String.format("Request: %s %s?%s from %s by %s", method, path, query, ip, user); String message = String.format("Request: %s %s?%s from %s by %s", method, path, query, ip, user);
Logger logger = LoggerFactory.getLogger(joinPoint.getTarget().getClass()); Logger logger = LoggerFactory.getLogger(joinPoint.getTarget().getClass());
@@ -86,6 +86,7 @@ public class PortalConfigSecurity {
// 운영(prod/eapim/devportal)은 동일 호스트(IP:PORT)에 여러 서비스가 떠 있어 // 운영(prod/eapim/devportal)은 동일 호스트(IP:PORT)에 여러 서비스가 떠 있어
// 쿠키가 호스트 단위로 공유·과포화되면서 XSRF-TOKEN 쿠키가 누락 → 로그인 403이 발생했다. // 쿠키가 호스트 단위로 공유·과포화되면서 XSRF-TOKEN 쿠키가 누락 → 로그인 403이 발생했다.
// 기존 클라이언트(X-XSRF-TOKEN 헤더, _csrf 파라미터)와 호환되도록 헤더명을 고정한다. // 기존 클라이언트(X-XSRF-TOKEN 헤더, _csrf 파라미터)와 호환되도록 헤더명을 고정한다.
// 세션에 저장되므로 CSRF 토큰 수명은 세션 타임아웃(10분)과 동일하다.
HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository(); HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository();
csrfTokenRepository.setHeaderName("X-XSRF-TOKEN"); csrfTokenRepository.setHeaderName("X-XSRF-TOKEN");
@@ -32,6 +32,7 @@ public class DjbTestbedGatewayProperty {
public static final String KEY_TIMEOUT_SEC = "djb.gateway.timeout"; public static final String KEY_TIMEOUT_SEC = "djb.gateway.timeout";
public static final String KEY_USE_PROXY = "djb.gateway.use-proxy"; public static final String KEY_USE_PROXY = "djb.gateway.use-proxy";
public static final String KEY_TOKEN_USE_PROXY = "djb.gateway.token-use-proxy"; public static final String KEY_TOKEN_USE_PROXY = "djb.gateway.token-use-proxy";
public static final String KEY_MOCK_ACCESS_TOKEN = "djb.gateway.mock-access-token";
public static final String DEFAULT_BASE_URL = "PortalMock"; public static final String DEFAULT_BASE_URL = "PortalMock";
public static final String DEFAULT_TIMEOUT_SEC = "10"; public static final String DEFAULT_TIMEOUT_SEC = "10";
@@ -47,6 +48,9 @@ public class DjbTestbedGatewayProperty {
/** PortalMock 모드에서 사용하는 포털 기존 mock 토큰 경로. */ /** PortalMock 모드에서 사용하는 포털 기존 mock 토큰 경로. */
public static final String PORTAL_MOCK_TOKEN_PATH = "/api/v1/oauth/token"; public static final String PORTAL_MOCK_TOKEN_PATH = "/api/v1/oauth/token";
/** PortalMock 모드 토큰 응답의 access_token 기본값. */
public static final String DEFAULT_MOCK_ACCESS_TOKEN = "djbank_gw_sample_token";
public String baseUrl() { public String baseUrl() {
return resolve(KEY_BASE_URL, DEFAULT_BASE_URL, return resolve(KEY_BASE_URL, DEFAULT_BASE_URL,
"GW Base URL. 문자열 \"PortalMock\" 이면 ApiTesterFilter 가 mock 토큰 반환"); "GW Base URL. 문자열 \"PortalMock\" 이면 ApiTesterFilter 가 mock 토큰 반환");
@@ -86,6 +90,12 @@ public class DjbTestbedGatewayProperty {
"테스트베드 토큰 발급 시 서버 프록시(/api/call-api) 사용 여부(true/false). false 이면 브라우저에서 직접 호출."), true); "테스트베드 토큰 발급 시 서버 프록시(/api/call-api) 사용 여부(true/false). false 이면 브라우저에서 직접 호출."), true);
} }
/** PortalMock 모드 토큰 응답에 넣을 access_token 값. */
public String mockAccessToken() {
return resolve(KEY_MOCK_ACCESS_TOKEN, DEFAULT_MOCK_ACCESS_TOKEN,
"PortalMock 모드 토큰 발급 응답의 access_token 값");
}
/** /**
* 프로퍼티 값을 boolean 으로 해석. 레거시 {@code Y/N} 값도 자동 변환한다. * 프로퍼티 값을 boolean 으로 해석. 레거시 {@code Y/N} 값도 자동 변환한다.
* {@code true}/{@code Y} → true, {@code false}/{@code N} → false, 그 외/null → {@code def}. * {@code true}/{@code Y} → true, {@code false}/{@code N} → false, 그 외/null → {@code def}.
@@ -25,8 +25,11 @@ import org.springframework.web.bind.annotation.RestController;
/** /**
* testbed spec(swagger.json/yaml)에 AUTHTYPE 기반 securityScheme 를 주입하고, * testbed spec(swagger.json/yaml)에 AUTHTYPE 기반 securityScheme 를 주입하고,
* 서버 sentinel({@link DjbTestbedSpecServerRewriter#SERVER_SENTINEL})을 API SPEC 설정(responseType)에 * 서버 sentinel({@link DjbTestbedSpecServerRewriter#SERVER_SENTINEL})을 실주소로 치환해 반환한다.
* 따른 실주소로 치환해 반환한다. * <ul>
* <li>{@code swagger.json}/{@code swagger.yaml} : 외부 공개/다운로드용 — 항상 GW 주소로 치환</li>
* <li>{@code swagger-ui.json} : Swagger UI 전용 — API SPEC 설정(responseType: sample/mock/gw)에 따라 치환</li>
* </ul>
* {@code default-token-api-spec} 은 클래스패스 기본 spec 을 그대로 반환(auth enrich 대상 외). * {@code default-token-api-spec} 은 클래스패스 기본 spec 을 그대로 반환(auth enrich 대상 외).
*/ */
@RestController @RestController
@@ -45,22 +48,34 @@ public class DjbTestbedSpecController {
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE) @GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException { public ResponseEntity<String> swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request); String json = buildSpecJson(id, request, true);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json); return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
} }
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml") @GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
public ResponseEntity<String> swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException { public ResponseEntity<String> swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request); String json = buildSpecJson(id, request, true);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json)); return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
} }
/** default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null. */ /** Swagger UI 전용 spec — 서버 주소를 responseType(sample/mock/gw) 설정에 따라 치환. */
private String buildSpecJson(String id, HttpServletRequest request) throws IOException { @GetMapping(value = "/{id}/swagger-ui.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> swaggerForUi(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request, false);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
}
/**
* default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null.
* @param alwaysGateway true 면 항상 GW 주소 치환(다운로드용), false 면 responseType 설정 기반(UI용)
*/
private String buildSpecJson(String id, HttpServletRequest request, boolean alwaysGateway) throws IOException {
if (DEFAULT_TOKEN_API_ID.equals(id)) { if (DEFAULT_TOKEN_API_ID.equals(id)) {
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH); Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8); String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
return serverRewriter.rewriteServer(content, null, request); return alwaysGateway
? serverRewriter.rewriteServerToGateway(content, request)
: serverRewriter.rewriteServer(content, null, request);
} }
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id); Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
@@ -70,6 +85,8 @@ public class DjbTestbedSpecController {
DjbAuthType authType = authService.resolveAuthType(id); DjbAuthType authType = authService.resolveAuthType(id);
String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType); String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType);
return serverRewriter.rewriteServer(enriched, spec.get(), request); return alwaysGateway
? serverRewriter.rewriteServerToGateway(enriched, request)
: serverRewriter.rewriteServer(enriched, spec.get(), request);
} }
} }
@@ -50,6 +50,22 @@ public class DjbTestbedSpecServerRewriter {
return specJson.replace(SERVER_SENTINEL, base); return specJson.replace(SERVER_SENTINEL, base);
} }
/**
* sentinel → GW 주소({@link DjbTestbedGatewayProperty#resolveApiBaseUrl}) 치환한 spec JSON 반환.
* responseType 을 무시하고 항상 GW 기준으로 치환한다 — 외부 공개/다운로드용 spec(swagger.json/yaml)
* 단일 기준. (Swagger UI 표시용은 {@link #rewriteServer} 의 responseType 분기를 그대로 사용.)
*/
public String rewriteServerToGateway(String specJson, HttpServletRequest request) {
if (specJson == null || !specJson.contains(SERVER_SENTINEL)) {
return specJson;
}
String base = stripTrailingSlash(gatewayProperty.resolveApiBaseUrl(originOf(request)));
if (!StringUtils.hasText(base)) {
return specJson; // 실주소 미확정 시 sentinel 유지
}
return specJson.replace(SERVER_SENTINEL, base);
}
/** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */ /** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */
public String toYaml(String specJson) { public String toYaml(String specJson) {
try { try {
+4 -5
View File
@@ -2,11 +2,10 @@ server:
servlet: servlet:
context-path: / context-path: /
session: session:
# 물리 세션 타임아웃은 DB PortalProperty(Portal/session.timeout.minutes)로 관리한다. # 세션 타임아웃 10분 고정 (DB property 관리 폐지).
# 로그인 성공 시 PortalAuthenticationSuccessHandler 가 # WebLogic 배포 시에는 weblogic.xml <timeout-secs>600 이 동일 값을 적용한다.
# session.setMaxInactiveInterval(session.timeout.minutes * 60) 으로 적용 → 물리=논리 일치. # CSRF 토큰은 세션에 저장(HttpSessionCsrfTokenRepository)되므로 수명도 이 값과 동일하다.
# 익명/로그인 전 세션은 컨테이너 기본값으로 fallback (weblogic.xml <timeout-secs>1800). timeout: 10m
# timeout: 10m
cookie: cookie:
name: JSESSIONID_PORTAL name: JSESSIONID_PORTAL
encoding: encoding:
+17
View File
@@ -56,6 +56,19 @@
</encoder> </encoder>
</appender> </appender>
<!-- API 테스트베드(/api/call-api) 감사 로그: 요청지/헤더/마스킹된 본문 기록, 1년(365일) 보관 -->
<appender name="API_TESTER_AUDIT" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/apitester-audit.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/backup/apitester-audit.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>200MB</maxFileSize>
<maxHistory>365</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %msg%n</pattern>
</encoder>
</appender>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter"> <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>${CONSOLE_EFFECTIVE_LEVEL}</level> <level>${CONSOLE_EFFECTIVE_LEVEL}</level>
@@ -68,6 +81,10 @@
<appender-ref ref="HTTP_SESSION" /> <appender-ref ref="HTTP_SESSION" />
</logger> </logger>
<logger name="eapim.portal.apitester.audit" level="INFO" additivity="false">
<appender-ref ref="API_TESTER_AUDIT" />
</logger>
<root level="INFO"> <root level="INFO">
<appender-ref ref="ROLLING"/> <appender-ref ref="ROLLING"/>
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -26,6 +26,9 @@
var startTs = 0; var startTs = 0;
var targetOpblock = null; var targetOpblock = null;
// Execute 클릭 후에만 true. spec 로딩 등 실행 외 응답이 responseInterceptor 로
// 들어와도 그리드를 만들지 않기 위한 게이트 (실행 전 UI 미노출 보장).
var pendingExecute = false;
function now() { function now() {
return (global.performance && performance.now) ? performance.now() : Date.now(); return (global.performance && performance.now) ? performance.now() : Date.now();
@@ -206,6 +209,7 @@
// 네트워크/CORS 실패 렌더: responseInterceptor 로 오지 않고 store 에만 error 로 남는 케이스. // 네트워크/CORS 실패 렌더: responseInterceptor 로 오지 않고 store 에만 error 로 남는 케이스.
function renderNetworkError(msg) { function renderNetworkError(msg) {
pendingExecute = false;
var grid = gridFor(targetOpblock); var grid = gridFor(targetOpblock);
if (!grid) return; if (!grid) return;
grid.classList.remove("djb-empty"); grid.classList.remove("djb-empty");
@@ -250,6 +254,7 @@
} }
startTs = now(); startTs = now();
pendingExecute = true;
var grid = gridFor(targetOpblock); var grid = gridFor(targetOpblock);
if (grid) { if (grid) {
grid.classList.add("djb-empty"); grid.classList.add("djb-empty");
@@ -264,10 +269,13 @@
*/ */
function renderResponse(res) { function renderResponse(res) {
if (!res) return; if (!res) return;
// spec 로딩 응답(/…/swagger.json)은 responseInterceptor 로도 들어온다. 이때 렌더하면 // Execute 를 누른 적 없으면 무시 — spec 로딩(/…/swagger.json) 응답도
// 실행 전인데 응답 본문에 OpenAPI spec 이 그려지므로 무시한다. 사용자 API 호출은 // responseInterceptor 로 들어오는데, 이때 렌더하면 실행 전 응답 본문에
// 프록시(/api/call-api)로 나가므로 url 에 swagger.json 이 없다. // OpenAPI spec 이 그려진다. url 검사는 버전에 따라 res.url 이 비어 무력화될
// 수 있어 실행 게이트를 1차 방어로 둔다.
if (!pendingExecute) return;
if (res.url && res.url.indexOf("swagger.json") !== -1) return; if (res.url && res.url.indexOf("swagger.json") !== -1) return;
pendingExecute = false;
var ms = Math.max(0, Math.round(now() - startTs)); var ms = Math.max(0, Math.round(now() - startTs));
var status = res.status || 0; var status = res.status || 0;
var statusText = res.statusText || ""; var statusText = res.statusText || "";
@@ -671,6 +671,139 @@
} }
} }
// Testbed 탭: 앱 선택 패널과 Swagger UI 사이 여백 제거
#testbed-tab {
gap: 0;
#swagger-ui .info {
margin: $spacing-lg 0;
}
}
// Testbed 앱 선택 패널 (DJPGPT0001)
.testbed-app-panel {
margin: $spacing-sm 0 0;
padding: $spacing-lg;
background: $white;
border: 1px solid $border-gray;
border-radius: $border-radius-lg;
box-shadow: $shadow-sm;
.testbed-app-panel__head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: $spacing-xs $spacing-sm;
margin-bottom: $spacing-md;
}
.testbed-app-panel__title {
position: relative;
padding-left: $spacing-md;
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $text-dark;
letter-spacing: -0.01em;
&::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 15px;
background: $primary-blue;
border-radius: 2px;
}
}
.testbed-app-panel__desc {
font-size: $font-size-sm;
color: $text-gray;
}
.testbed-app-field {
position: relative;
max-width: 380px;
&::after {
content: "";
position: absolute;
right: $spacing-md;
top: 50%;
width: 9px;
height: 9px;
margin-top: -6px;
border-right: 2px solid $text-gray;
border-bottom: 2px solid $text-gray;
transform: rotate(45deg);
pointer-events: none;
}
}
#apps {
width: 100%;
height: 46px;
padding: 0 42px 0 $input-padding-x;
font-family: $font-family-primary;
font-size: $font-size-sm;
color: $text-dark;
background: $gray-bg;
border: 1px solid $border-gray;
border-radius: $border-radius-md;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
cursor: pointer;
transition: $transition-fast;
&:hover:not(:disabled) {
border-color: $text-light;
background: $white;
}
&:focus {
outline: none;
background: $white;
border-color: $primary-blue;
box-shadow: 0 0 0 3px rgba($primary-blue, 0.14);
}
&:disabled {
color: $text-light;
background: $gray-bg;
cursor: not-allowed;
}
}
.testbed-app-notice {
margin: $spacing-md 0 0;
padding: 11px $spacing-md;
font-size: $font-size-sm;
line-height: $line-height-normal;
color: $text-gray;
background: $light-bg;
border: 1px solid rgba($primary-blue, 0.18);
border-left: 3px solid $primary-blue;
border-radius: $border-radius-sm;
&::before {
content: "";
color: $primary-blue;
font-weight: $font-weight-bold;
}
}
@media (max-width: $breakpoint-sm) {
padding: $spacing-md;
.testbed-app-field {
max-width: 100%;
}
}
}
// API Overview Card (Flat Style) // API Overview Card (Flat Style)
.api-overview-card { .api-overview-card {
background: transparent; background: transparent;
@@ -777,7 +910,11 @@
border-bottom: none; border-bottom: none;
} }
.detail-content { // [editor-content 정렬] Summernote(관리자) 작성 콘텐츠는 editor-content.css 가 전담 스타일링하므로
// 자체 테이블/폰트 스타일은 .editor-content 미적용 영역(샘플 pre/code)에만 건다.
// 과거 회귀: :not(.editor-content) 를 지워 .detail-content 로 되돌리고,
// mainApiDetail.html 의 editor-content 클래스 3곳을 제거하면 기존 스타일로 복원됨
.detail-content:not(.editor-content) {
font-size: $font-size-sm; font-size: $font-size-sm;
color: $text-gray; color: $text-gray;
line-height: $line-height-normal; line-height: $line-height-normal;
@@ -987,8 +1124,16 @@
margin-bottom: $spacing-sm; margin-bottom: $spacing-sm;
} }
// [editor-content 정렬] Summernote 콘텐츠는 editor-content.css 모바일 규칙 사용,
// 넘치는 표 대비 가로 스크롤만 부여. 과거 회귀 시 이 블록 삭제
.detail-content.editor-content {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
// Detail Content - 모바일에서 가로 스크롤 지원 // Detail Content - 모바일에서 가로 스크롤 지원
.detail-content { // 과거 회귀: :not(.editor-content) 제거
.detail-content:not(.editor-content) {
font-size: $font-size-xs; font-size: $font-size-xs;
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
@@ -1037,7 +1037,7 @@ $o2leg-err-fg: #a23b3b;
// -- Section 2: 사전 준비 ---------------------------------------------------- // -- Section 2: 사전 준비 ----------------------------------------------------
&__prereq-grid { &__prereq-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr; grid-template-columns: 1fr;
gap: 16px; gap: 16px;
} }
@@ -1108,18 +1108,28 @@ $o2leg-err-fg: #a23b3b;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: 24px;
// 연속 배치된 grid 사이 간격을 내부 gap(24px)과 동일하게 유지
// (언어별 코드 블럭이 두 grid 에 나뉘어 있어 간격이 달라 보이는 문제 방지)
+ .oauth2-2legged__step-grid {
margin-top: 24px;
}
} }
&__endpoint-box { &__endpoint-box {
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: 1fr;
align-items: center; gap: 8px;
gap: 16px;
padding: 16px 24px; padding: 16px 24px;
margin-bottom: 20px; margin-bottom: 20px;
background: #FFFFFF; background: #FFFFFF;
border: 1px solid $o2leg-border; border: 1px solid $o2leg-border;
border-radius: 12px; border-radius: 12px;
.oauth2-2legged__method,
.oauth2-2legged__endpoint-content-type {
justify-self: start;
}
} }
&__method { &__method {
@@ -86,7 +86,9 @@
<div class="api-simple-description" th:if="${apiSpecInfo.apiSimpleDescription != null}"> <div class="api-simple-description" th:if="${apiSpecInfo.apiSimpleDescription != null}">
<p th:text="${apiSpecInfo.apiSimpleDescription}">Simple API description</p> <p th:text="${apiSpecInfo.apiSimpleDescription}">Simple API description</p>
</div> </div>
<div class="detail-content" th:utext="${apiSpecInfo.description}"> <!--/* [editor-content] 관리자포탈 Summernote 콘텐츠 스타일 정렬(editor-content.css).
과거 스타일(자체 detail-content SCSS)로 회귀하려면 editor-content 클래스만 제거 */-->
<div class="detail-content editor-content" th:utext="${apiSpecInfo.description}">
Detailed API description Detailed API description
</div> </div>
</div> </div>
@@ -99,7 +101,8 @@
<div class="org-section-header org-section-header--agreement"> <div class="org-section-header org-section-header--agreement">
<h3>Request Specification</h3> <h3>Request Specification</h3>
</div> </div>
<div class="detail-content" th:utext="${apiSpecInfo.apiRequestSpec}"> <!--/* [editor-content] 회귀 시 editor-content 클래스 제거 */-->
<div class="detail-content editor-content" th:utext="${apiSpecInfo.apiRequestSpec}">
Request spec Request spec
</div> </div>
</div> </div>
@@ -119,7 +122,8 @@
<div class="org-section-header org-section-header--agreement"> <div class="org-section-header org-section-header--agreement">
<h3>Response Specification</h3> <h3>Response Specification</h3>
</div> </div>
<div class="detail-content" th:utext="${apiSpecInfo.apiResponseSpec}"> <!--/* [editor-content] 회귀 시 editor-content 클래스 제거 */-->
<div class="detail-content editor-content" th:utext="${apiSpecInfo.apiResponseSpec}">
Response spec Response spec
</div> </div>
</div> </div>
@@ -294,7 +298,9 @@
return; return;
} }
const swaggerUrl = `/djb/testbed/apis/${apiId}/swagger.json`; // UI 전용 spec: 서버 주소가 responseType(sample/mock/gw) 설정에 따라 치환됨.
// (swagger.json 은 외부 다운로드용으로 항상 GW 주소 고정)
const swaggerUrl = `/djb/testbed/apis/${apiId}/swagger-ui.json`;
// Load Swagger UI scripts dynamically // Load Swagger UI scripts dynamically
const loadScript = (src) => { const loadScript = (src) => {
@@ -115,6 +115,24 @@
form.checkId.checked = ((form.id.value = getCookie('saveid')) !== null); form.checkId.checked = ((form.id.value = getCookie('saveid')) !== null);
} }
// CSRF 토큰 재발급 안전망: keepalive ping이 끊긴 경우(절전·네트워크 단절 등)
// 세션이 만료됐어도 제출 직전 새 토큰을 받아 403(만료) 대신 정상 로그인되게 한다.
// 재발급 실패 시에는 기존 토큰으로 그대로 제출한다.
function refreshCsrfAndThen(form, next) {
$.ajax({
url: /*[[@{/api/session/csrf}]]*/ '/api/session/csrf',
type: 'GET',
success: function (data) {
if (data && data.token) {
if (form['_csrf']) { form['_csrf'].value = data.token; }
var meta = document.querySelector('meta[name="_csrf"]');
if (meta) { meta.content = data.token; }
}
},
complete: function () { next(); }
});
}
// 로그인 전 중복 접속 확인 → 중복 시 기존 세션 강제 로그아웃 여부 질의 // 로그인 전 중복 접속 확인 → 중복 시 기존 세션 강제 로그아웃 여부 질의
function checkDuplicateAndLogin(form) { function checkDuplicateAndLogin(form) {
var loginId = form.id.value; var loginId = form.id.value;
@@ -157,26 +175,8 @@
$(function () { $(function () {
// CSRF 토큰은 세션 기반 → 세션 만료(server.servlet.session.timeout=10m) 전에 // 세션 만료 대응은 head의 익명 keepalive ping(/api/session/ping)이 담당하고,
// 메인 페이지로 이동시켜 stale 토큰 제출(403) 방지. 이동은 서버 요청이라 세션 idle도 리셋됨. // 제출 직전 refreshCsrfAndThen()이 CSRF 토큰 재발급 안전망 역할을 한다.
// 단, 입력 중에는 이탈 금지 → idle 타이머(활동 시 리셋, 입력값/포커스 있으면 보류).
// 만료 시간은 하드코딩하지 않고 서버 세션 타임아웃(초)을 렌더 시점에 읽어 파생 → yml 변경 시 자동 반영.
var SESSION_TIMEOUT_SEC = /*[[${#request.session.maxInactiveInterval}]]*/ 600;
var LOGIN_IDLE_LIMIT_MS = Math.max(60, SESSION_TIMEOUT_SEC - 120) * 1000; // 세션 만료 2분 전
var loginIdleTimer;
function scheduleIdleRedirect() {
clearTimeout(loginIdleTimer);
loginIdleTimer = setTimeout(function () {
var idEl = document.getElementById('id');
var pwEl = document.getElementById('password');
var busy = document.activeElement === idEl || document.activeElement === pwEl
|| (idEl && idEl.value) || (pwEl && pwEl.value);
if (busy) { scheduleIdleRedirect(); return; } // 입력 중/입력값 있음 → 이탈 보류
window.location.href = /*[[@{/}]]*/ '/';
}, LOGIN_IDLE_LIMIT_MS);
}
$('#id, #password, #checkId').on('input keydown focus click', scheduleIdleRedirect);
scheduleIdleRedirect();
var successMsg = [[${success}]]; var successMsg = [[${success}]];
console.log("Success message:", successMsg); console.log("Success message:", successMsg);
@@ -225,7 +225,9 @@
$('#loginLoading').removeClass('active'); $('#loginLoading').removeClass('active');
customPopups.showAlert('[[#{login.passLengthShort}]]'); customPopups.showAlert('[[#{login.passLengthShort}]]');
} else { } else {
checkDuplicateAndLogin(form); refreshCsrfAndThen(form, function () {
checkDuplicateAndLogin(form);
});
} }
} }
form.classList.add('was-validated'); form.classList.add('was-validated');
File diff suppressed because one or more lines are too long
@@ -111,13 +111,13 @@
</div> </div>
</div> </div>
<!-- Call Back URL : OAuth2 3-legged 전용. 현재 미사용으로 주석 처리 (추후 OAuth2 도입 시 복원) <!--/* Call Back URL : OAuth2 3-legged 전용. 현재 미사용으로 주석 처리 (추후 OAuth2 도입 시 복원)
<div class="s1-field"> <div class="s1-field">
<label class="s1-label">Call Back URL</label> <label class="s1-label">Call Back URL</label>
<input type="text" id="callbackUrl" name="callbackUrl" th:field="*{callbackUrl}" class="s1-input" <input type="text" id="callbackUrl" name="callbackUrl" th:field="*{callbackUrl}" class="s1-input"
placeholder="URL을 입력해 주세요."> placeholder="URL을 입력해 주세요.">
</div> </div>
--> */-->
<!-- 화이트 리스트 --> <!-- 화이트 리스트 -->
<div class="s1-field"> <div class="s1-field">
@@ -48,7 +48,7 @@
<!-- Step 1 --> <!-- Step 1 -->
<div class="signup-step"> <div class="signup-step">
<div class="signup-step__icon-box one"> <div class="signup-step__icon-box one">
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none" <!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg"> xmlns="http://www.w3.org/2000/svg">
<path <path
d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z" d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z"
@@ -65,7 +65,7 @@
d="M19 14C19 12.8954 19.8954 12 21 12C22.1046 12 23 12.8954 23 14C23 15.1046 22.1046 16 21 16C19.8954 16 19 15.1046 19 14Z" d="M19 14C19 12.8954 19.8954 12 21 12C22.1046 12 23 12.8954 23 14C23 15.1046 22.1046 16 21 16C19.8954 16 19 15.1046 19 14Z"
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" /> stroke-linejoin="round" />
</svg> --> </svg> */-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-person-add" viewBox="0 0 16 16"> class="bi bi-person-add" viewBox="0 0 16 16">
<path <path
@@ -83,7 +83,7 @@
<!-- Step 2 --> <!-- Step 2 -->
<div class="signup-step"> <div class="signup-step">
<div class="signup-step__icon-box two"> <div class="signup-step__icon-box two">
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none" <!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg"> xmlns="http://www.w3.org/2000/svg">
<path <path
d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z" d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z"
@@ -95,7 +95,7 @@
stroke-linejoin="round" /> stroke-linejoin="round" />
<path d="M19 16L21 18L25 14" stroke="#0049B4" stroke-width="1.6" <path d="M19 16L21 18L25 14" stroke="#0049B4" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" /> stroke-linecap="round" stroke-linejoin="round" />
</svg> --> </svg> */-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-journal-check" viewBox="0 0 16 16"> class="bi bi-journal-check" viewBox="0 0 16 16">
<path fill-rule="evenodd" <path fill-rule="evenodd"
@@ -115,14 +115,14 @@
<!-- Step 3 --> <!-- Step 3 -->
<div class="signup-step"> <div class="signup-step">
<div class="signup-step__icon-box three"> <div class="signup-step__icon-box three">
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none" <!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg"> xmlns="http://www.w3.org/2000/svg">
<rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6" <rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" /> stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 12H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" /> <path d="M12 12H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M12 16H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" /> <path d="M12 16H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M12 20H16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" /> <path d="M12 20H16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
</svg> --> </svg> */-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-code" viewBox="0 0 16 16"> class="bi bi-code" viewBox="0 0 16 16">
<path <path
@@ -138,13 +138,13 @@
<!-- Step 4 --> <!-- Step 4 -->
<div class="signup-step"> <div class="signup-step">
<div class="signup-step__icon-box four"> <div class="signup-step__icon-box four">
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none" <!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg"> xmlns="http://www.w3.org/2000/svg">
<path d="M21 8H24V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" <path d="M21 8H24V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" /> stroke-linejoin="round" />
<path d="M8 8H11V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" <path d="M8 8H11V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" /> stroke-linejoin="round" />
</svg> --> </svg> */-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-display" viewBox="0 0 16 16"> class="bi bi-display" viewBox="0 0 16 16">
<path <path
@@ -160,14 +160,14 @@
<!-- Step 5 --> <!-- Step 5 -->
<div class="signup-step"> <div class="signup-step">
<div class="signup-step__icon-box five"> <div class="signup-step__icon-box five">
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"--> <!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"*/-->
<!-- xmlns="http://www.w3.org/2000/svg">--> <!-- xmlns="http://www.w3.org/2000/svg">-->
<!-- <rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"--> <!--/* <rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"*/-->
<!-- stroke-linecap="round" stroke-linejoin="round" />--> <!-- stroke-linecap="round" stroke-linejoin="round" />-->
<!-- <path d="M12 11H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />--> <!--/* <path d="M12 11H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />*/-->
<!-- <path d="M12 15L14.5 17.5L20 12" stroke="#0049B4" stroke-width="1.6"--> <!--/* <path d="M12 15L14.5 17.5L20 12" stroke="#0049B4" stroke-width="1.6"*/-->
<!-- stroke-linecap="round" stroke-linejoin="round" />--> <!-- stroke-linecap="round" stroke-linejoin="round" />-->
<!-- </svg>--> <!--/* </svg>*/-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-ui-checks-grid" viewBox="0 0 16 16"> class="bi bi-ui-checks-grid" viewBox="0 0 16 16">
<path <path
@@ -183,7 +183,7 @@
<!-- Step 6 --> <!-- Step 6 -->
<div class="signup-step"> <div class="signup-step">
<div class="signup-step__icon-box six"> <div class="signup-step__icon-box six">
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none" <!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg"> xmlns="http://www.w3.org/2000/svg">
<rect x="7" y="9" width="18" height="16" rx="2" stroke="#0049B4" stroke-width="1.6" <rect x="7" y="9" width="18" height="16" rx="2" stroke="#0049B4" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" /> stroke-linecap="round" stroke-linejoin="round" />
@@ -192,7 +192,7 @@
<path d="M20 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" /> <path d="M20 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M12 17H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" /> <path d="M12 17H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M12 21H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" /> <path d="M12 21H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
</svg> --> </svg> */-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-airplane" viewBox="0 0 16 16"> class="bi bi-airplane" viewBox="0 0 16 16">
<path <path
@@ -92,7 +92,7 @@
<span class="oauth2-2legged__prereq-num">3</span> <span class="oauth2-2legged__prereq-num">3</span>
<div class="oauth2-2legged__prereq-body"> <div class="oauth2-2legged__prereq-body">
<h3 class="oauth2-2legged__prereq-title">Scope 확인</h3> <h3 class="oauth2-2legged__prereq-title">Scope 확인</h3>
<p>호출하려는 API에 필요한 scope</p> <p>scope 는 고정값 "api" 사용</p>
<p>권한이 부여됐는지 확인합니다.</p> <p>권한이 부여됐는지 확인합니다.</p>
</div> </div>
</article> </article>
@@ -136,7 +136,7 @@
<text x="560" y="102" text-anchor="middle" font-size="12" font-weight="700" <text x="560" y="102" text-anchor="middle" font-size="12" font-weight="700"
fill="#0049b4">① POST /dj/oauth/token</text> fill="#0049b4">① POST /dj/oauth/token</text>
<text x="560" y="118" text-anchor="middle" font-size="11" font-weight="500" <text x="560" y="118" text-anchor="middle" font-size="11" font-weight="500"
fill="#64748B">grant_type=client_credentials, client_id, client_secret, scope</text> fill="#64748B">grant_type=client_credentials, client_id, client_secret, scope=api</text>
<line x1="240" y1="128" x2="880" y2="128" stroke="#0049b4" stroke-width="2" <line x1="240" y1="128" x2="880" y2="128" stroke="#0049b4" stroke-width="2"
marker-end="url(#o2leg-arrow-primary)" /> marker-end="url(#o2leg-arrow-primary)" />
@@ -145,11 +145,11 @@
<line x1="880" y1="170" x2="240" y2="170" stroke="#64748B" stroke-width="2" <line x1="880" y1="170" x2="240" y2="170" stroke="#64748B" stroke-width="2"
marker-end="url(#o2leg-arrow-gray)" /> marker-end="url(#o2leg-arrow-gray)" />
<text x="560" y="188" text-anchor="middle" font-size="11" font-weight="500" <text x="560" y="188" text-anchor="middle" font-size="11" font-weight="500"
fill="#64748B">{ access_token, token_type:"bearer", expires_in:86400, scope, jti fill="#64748B">{ access_token, token_type:"bearer", expires_in:86400, scope:"api", jti
}</text> }</text>
<text x="560" y="216" text-anchor="middle" font-size="12" font-weight="700" <text x="560" y="216" text-anchor="middle" font-size="12" font-weight="700"
fill="#0049b4">③ GET /api/v1/... · Authorization: Bearer &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" <line x1="240" y1="226" x2="880" y2="226" stroke="#0049b4" stroke-width="2"
marker-end="url(#o2leg-arrow-primary)" /> marker-end="url(#o2leg-arrow-primary)" />
@@ -210,9 +210,9 @@
<tr> <tr>
<td><code>scope</code></td> <td><code>scope</code></td>
<td><span <td><span
class="oauth2-2legged__req-badge oauth2-2legged__req-badge--optional">선택</span> class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">필수</span>
</td> </td>
<td>호출 권한 범위 (공백 구분)</td> <td>고정값 "api"</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -228,7 +228,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
-d <span class="o2leg-c">'grant_type=client_credentials'</span> \ -d <span class="o2leg-c">'grant_type=client_credentials'</span> \
-d <span class="o2leg-c">'client_id=YOUR_CLIENT_ID'</span> \ -d <span class="o2leg-c">'client_id=YOUR_CLIENT_ID'</span> \
-d <span class="o2leg-c">'client_secret=YOUR_CLIENT_SECRET'</span> \ -d <span class="o2leg-c">'client_secret=YOUR_CLIENT_SECRET'</span> \
-d <span class="o2leg-c">'scope=read.accounts'</span> -d <span class="o2leg-c">'scope=api'</span>
<span class="o2leg-g"># 응답: 200 OK + JSON</span></pre> <span class="o2leg-g"># 응답: 200 OK + JSON</span></pre>
</div> </div>
@@ -248,7 +248,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
<span class="o2leg-c">"access_token"</span>: <span class="o2leg-y">"eyJhbGciOiJSUzI1NiJ9..."</span>, <span class="o2leg-c">"access_token"</span>: <span class="o2leg-y">"eyJhbGciOiJSUzI1NiJ9..."</span>,
<span class="o2leg-c">"token_type"</span>: <span class="o2leg-y">"bearer"</span>, <span class="o2leg-c">"token_type"</span>: <span class="o2leg-y">"bearer"</span>,
<span class="o2leg-c">"expires_in"</span>: <span class="o2leg-p">86400</span>, <span class="o2leg-c">"expires_in"</span>: <span class="o2leg-p">86400</span>,
<span class="o2leg-c">"scope"</span>: <span class="o2leg-y">"read.accounts"</span>, <span class="o2leg-c">"scope"</span>: <span class="o2leg-y">"api"</span>,
<span class="o2leg-c">"jti"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-..."</span> <span class="o2leg-c">"jti"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-..."</span>
} }
@@ -284,7 +284,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
<tr> <tr>
<td><code class="oauth2-2legged__field-name">scope</code></td> <td><code class="oauth2-2legged__field-name">scope</code></td>
<td>string</td> <td>string</td>
<td>실제 부여된 권한 범위</td> <td>부여된 권한 범위 (api)</td>
</tr> </tr>
<tr> <tr>
<td><code class="oauth2-2legged__field-name">jti</code></td> <td><code class="oauth2-2legged__field-name">jti</code></td>
@@ -301,13 +301,13 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
<section class="oauth2-2legged__step" aria-labelledby="o2leg-step3-title"> <section class="oauth2-2legged__step" aria-labelledby="o2leg-step3-title">
<span class="oauth2-2legged__eyebrow">STEP 3</span> <span class="oauth2-2legged__eyebrow">STEP 3</span>
<h2 class="oauth2-2legged__h2" id="o2leg-step3-title">발급 토큰으로 API 호출</h2> <h2 class="oauth2-2legged__h2" id="o2leg-step3-title">발급 토큰으로 API 호출</h2>
<p class="oauth2-2legged__desc">Authorization 헤더에 Bearer 토큰을 실어 보호 자원 API 를 호출합니다.</p> <p class="oauth2-2legged__desc">X-AUTH-TOKEN 헤더에 Bearer 토큰을 실어 보호 자원 API 를 호출합니다.</p>
<div class="oauth2-2legged__step-grid"> <div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__panel"> <div class="oauth2-2legged__panel">
<h3 class="oauth2-2legged__panel-title">필수 헤더</h3> <h3 class="oauth2-2legged__panel-title">필수 헤더</h3>
<div class="oauth2-2legged__header-box"> <div class="oauth2-2legged__header-box">
<code class="oauth2-2legged__header-key">Authorization:</code> <code class="oauth2-2legged__header-key">X-AUTH-TOKEN:</code>
<code class="oauth2-2legged__header-value">Bearer eyJhbGciOiJSUzI1NiJ9...</code> <code class="oauth2-2legged__header-value">Bearer eyJhbGciOiJSUzI1NiJ9...</code>
</div> </div>
@@ -316,8 +316,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
<li>동일 토큰은 expires_in(기본 86400초) 동안 재사용</li> <li>동일 토큰은 expires_in(기본 86400초) 동안 재사용</li>
<li>만료 임박 시 재발급 후 교체 (예: TTL의 80% 시점)</li> <li>만료 임박 시 재발급 후 교체 (예: TTL의 80% 시점)</li>
<li>매 호출마다 토큰을 새로 발급하지 마세요</li> <li>매 호출마다 토큰을 새로 발급하지 마세요</li>
<li>권한이 다른 API 는 scope 별로 토큰을 분리 발급</li> </ul>
</ul>
</div> </div>
<div class="oauth2-2legged__code-panel"> <div class="oauth2-2legged__code-panel">
@@ -325,7 +324,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 보호 자원 API 호출</span> <pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 보호 자원 API 호출</span>
curl -X <span class="o2leg-y">GET</span> \ curl -X <span class="o2leg-y">GET</span> \
<span class="o2leg-c">'https://openapi.djbank.co.kr/api/v1/accounts'</span> \ <span class="o2leg-c">'https://openapi.djbank.co.kr/api/v1/accounts'</span> \
-H <span class="o2leg-c">'Authorization: Bearer eyJhbGciOiJSUzI..'</span> \ -H <span class="o2leg-c">'X-AUTH-TOKEN: Bearer eyJhbGciOiJSUzI..'</span> \
-H <span class="o2leg-c">'Accept: application/json'</span> -H <span class="o2leg-c">'Accept: application/json'</span>
<span class="o2leg-g"># 응답</span> <span class="o2leg-g"># 응답</span>
@@ -398,12 +398,12 @@ valid = constantTimeEquals(received, expected)</pre>
<tr> <tr>
<td><code>400</code></td> <td><code>400</code></td>
<td>필수 헤더 누락</td> <td>필수 헤더 누락</td>
<td>실패 기록 + 재시도</td> <td>실패 기록</td>
</tr> </tr>
<tr> <tr>
<td><code>401</code></td> <td><code>401</code></td>
<td>서명 불일치 / timestamp 만료</td> <td>서명 불일치 / timestamp 만료</td>
<td>실패 기록 + 재시도</td> <td>실패 기록</td>
</tr> </tr>
<tr> <tr>
<td><code>5xx</code> · 타임아웃</td> <td><code>5xx</code> · 타임아웃</td>
@@ -412,8 +412,9 @@ valid = constantTimeEquals(received, expected)</pre>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<p class="oauth2-2legged__warning">2xx 이외 응답과 네트워크 오류는 <strong>일정 간격을 두고 <p class="oauth2-2legged__warning">5xx 응답·타임아웃과 네트워크 오류는 <strong>일정 간격을 두고
재시도</strong>됩니다(기본 3회). 재시도로 인한 중복 수신은 <code>eventId</code> 멱등 처리로 방어하세요.</p> 재시도</strong>됩니다(기본 3회). 재시도로 인한 중복 수신은 <code>eventId</code> 멱등 처리로 방어하세요.</p>
>>>>>>> 9981459691c836bfd33b6b81a8e6aa22ca12446a
<h4 class="oauth2-2legged__panel-subtitle">응답 가이드</h4> <h4 class="oauth2-2legged__panel-subtitle">응답 가이드</h4>
<ul class="oauth2-2legged__tips"> <ul class="oauth2-2legged__tips">
@@ -246,7 +246,7 @@
<div class="error-container"> <div class="error-container">
<!-- Error Icon --> <!-- Error Icon -->
<div class="error-icon"> <div class="error-icon">
<!-- 3D Speech Bubble Error Icon - Base64 encoded or use th:src for server image --> <!--/* 3D Speech Bubble Error Icon - Base64 encoded or use th:src for server image */-->
<img th:src="@{/img/error_3d.png}" alt="에러"> <img th:src="@{/img/error_3d.png}" alt="에러">
</div> </div>
@@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<body> <body>
<!-- <!--/*
API 선택 공용 모듈 (figma s2 디자인: 카테고리 캐러셀 탭 + 카드그리드 + 플로팅 카트 + 선택목록 모달) API 선택 공용 모듈 (figma s2 디자인: 카테고리 캐러셀 탭 + 카드그리드 + 플로팅 카트 + 선택목록 모달)
사용처: 앱(API Key) 신청/수정 step2, Webhook 신청/수정 step2 사용처: 앱(API Key) 신청/수정 step2, Webhook 신청/수정 step2
@@ -25,7 +25,7 @@
- 추가 hidden 필드는 호출 페이지에서 form="apiSelectorForm" 속성으로 주입(예: apikey 수정 clientId). - 추가 hidden 필드는 호출 페이지에서 form="apiSelectorForm" 속성으로 주입(예: apikey 수정 clientId).
- API 목록: GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX. - API 목록: GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX.
- 스타일: design s2-* (_apikey-register.scss step2 재작업분) 재사용. - 스타일: design s2-* (_apikey-register.scss step2 재작업분) 재사용.
--> */-->
<th:block th:fragment="apiSelector(apiServices, selectedApis, formAction, saveAction)"> <th:block th:fragment="apiSelector(apiServices, selectedApis, formAction, saveAction)">
<!-- Category Carousel Tab Container --> <!-- Category Carousel Tab Container -->
@@ -106,7 +106,7 @@
<p class="pop_text l_text2">신청사유</p> <p class="pop_text l_text2">신청사유</p>
<div class="pop_textbox"> <div class="pop_textbox">
<textarea name="reason" rows="5" cols="30" class="common_textareaType_1" placeholder="신청사유를 입력해 주세요."></textarea> <textarea name="reason" rows="5" cols="30" class="common_textareaType_1" placeholder="신청사유를 입력해 주세요."></textarea>
<!-- <span>158/1,000 byte</span> --> <!--/* <span>158/1,000 byte</span> */-->
</div> </div>
<div class="pop_btnbox"> <div class="pop_btnbox">
<button class="popup_button_gray btn_cancel">취소</button> <button class="popup_button_gray btn_cancel">취소</button>
@@ -1,32 +0,0 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<aside layout:fragment="apiAside" class="lnb">
<nav th:with="services=${@apiServiceService.searchApiGroupsForLnb()}">
<ul class="lnb_list_type">
<li>
<a href="#none">공통안내</a>
<ul class="lnb_list_nav">
<li> <a th:href="@{/apis/common}">API 개발 공통</a></li>
<li> <a th:href="@{/apis/KAPAP004U3}">가상계좌 응답 코드</a></li>
<li> <a th:href="@{/apis/KAPAP004U4}">가상계좌 배치 설계</a></li>
<li> <a th:href="@{/apis/KAPAP004U5}">가상계좌 VAN사 코드</a></li>
<li> <a th:href="@{/apis/KAPAP004U6}">펌뱅킹 응답코드</a></li>
<li> <a th:href="@{/apis/KAPAP004U7}">펌뱅킹 배치 설계</a></li>
<li> <a th:href="@{/apis/KAPAP004U8}">대출금리 응답코드</a></li>
<li> <a th:href="@{/apis/KAPAP004U9}">케이뱅크 페이 응답코드</a></li>
<li> <a th:href="@{/apis/KAPAP004U10}">케이뱅크 페이 복합과세 예제</a></li>
<li> <a th:href="@{/apis/token-spec}">케이뱅크 OAuth 2.0 토큰 발급</a></li>
</ul>
</li>
<li th:each="apiService : ${services}">
<a href="#none" th:text="${apiService.groupName}">API Group Name</a>
<ul class="lnb_list_nav">
<li th:each="api : ${apiService.apiGroupApiList}">
<a th:href="@{/apis/detail(id=${api.apiId})}" th:text="${api.apiDesc}">API Description</a>
</li>
</ul>
</li>
</ul>
</nav>
</aside>
@@ -1,5 +1,6 @@
<!doctype html> <!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"> <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head th:fragment="headFragment"> <head th:fragment="headFragment">
<title th:text="#{title.html}">개발자 포털</title> <title th:text="#{title.html}">개발자 포털</title>
@@ -27,6 +28,23 @@
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. --> <!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/> <meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/> <meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/>
<!-- 익명(비로그인) 세션 keepalive: 로그인/회원가입 등에서 페이지에 머무는 동안 주기적 ping으로
세션 비활성 타이머를 리셋해 세션 저장 CSRF 토큰·회원가입 본인인증 상태의 만료(10분)를 방지한다.
탭을 닫으면 ping이 멈춰 정상 만료. 인증 사용자는 헤더의 세션 타이머/heartbeat가 대신 처리한다. -->
<script sec:authorize="isAnonymous()" th:inline="javascript">
(function () {
var PING_URL = /*[[@{/api/session/ping}]]*/ '/api/session/ping';
var PING_INTERVAL_MS = 4 * 60 * 1000; // 세션 타임아웃(10분)의 절반 이하
setInterval(function () {
try {
var xhr = new XMLHttpRequest();
xhr.open('GET', PING_URL, true);
xhr.send();
} catch (ignore) { /* keepalive 실패는 화면 동작에 영향 없음 */ }
}, PING_INTERVAL_MS);
})();
</script>
<meta content="max-age=0, public" http-equiv="Cache-Control"/> <meta content="max-age=0, public" http-equiv="Cache-Control"/>
<meta content="index, follow" name="robots"/> <meta content="index, follow" name="robots"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
@@ -100,9 +118,9 @@
<script th:src="@{/plugins/summernote/summernote-cleaner.js}"></script> <script th:src="@{/plugins/summernote/summernote-cleaner.js}"></script>
<script th:src="@{/plugins/jquery-ui/jquery-ui.min.js}"></script> <script th:src="@{/plugins/jquery-ui/jquery-ui.min.js}"></script>
<!-- LiveReload (개발 전용): DevTools 가 정적 리소스/템플릿 변경을 감지하면 브라우저를 자동 새로고침한다. <!--/* LiveReload (개발 전용): DevTools 가 정적 리소스/템플릿 변경을 감지하면 브라우저를 자동 새로고침한다.
localhost 외 IP(예: 172.30.1.14)로 접속해도 동작하도록 접속 호스트 기준으로 livereload.js 를 로드한다. localhost 외 IP(예: 172.30.1.14)로 접속해도 동작하도록 접속 호스트 기준으로 livereload.js 를 로드한다.
prod/stage 에는 DevTools 자체가 없으므로(developmentOnly) 개발 프로파일에서만 주입한다. --> prod/stage 에는 DevTools 자체가 없으므로(developmentOnly) 개발 프로파일에서만 주입한다. */-->
<script th:if="${@environment.acceptsProfiles('local_rinjaemac','local')}" <script th:if="${@environment.acceptsProfiles('local_rinjaemac','local')}"
th:src="|//${#request.serverName}:35729/livereload.js|"></script> th:src="|//${#request.serverName}:35729/livereload.js|"></script>
</head> </head>
@@ -21,12 +21,12 @@
<!-- Desktop Header Layout --> <!-- Desktop Header Layout -->
<div class="desktop-header"> <div class="desktop-header">
<div class="header-left"> <div class="header-left">
<!-- <div class="logo-wrapper">--> <!--/* <div class="logo-wrapper">*/-->
<!-- <a th:href="@{/}" class="logo-link">--> <!--/* <a th:href="@{/}" class="logo-link">*/-->
<!-- <img src="/img/logo/logo-djb.png" alt="DJBank" class="logo" width="114" height="32">--> <!--/* <img src="/img/logo/logo-djb.png" alt="DJBank" class="logo" width="114" height="32">*/-->
<!-- </a>--> <!--/* </a>*/-->
<!-- <a th:href="@{/}" class="logo-text">API Portal</a>--> <!--/* <a th:href="@{/}" class="logo-text">API Portal</a>*/-->
<!-- </div>--> <!--/* </div>*/-->
<div > <div >
<div class="logo"> <div class="logo">
<a th:href="@{/}" class="logo-wrapper"> <a th:href="@{/}" class="logo-wrapper">
@@ -63,15 +63,15 @@
<div class="header-right"> <div class="header-right">
<!-- Login State (Anonymous) --> <!-- Login State (Anonymous) -->
<!-- <div class="auth-group" sec:authorize="isAnonymous()">--> <!--/* <div class="auth-group" sec:authorize="isAnonymous()">*/-->
<!-- <a th:href="@{/login}" class="login-btn login-btn-box">--> <!--/* <a th:href="@{/login}" class="login-btn login-btn-box">*/-->
<!-- <img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">로그인</a>--> <!--/* <img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">로그인</a>*/-->
<!-- </div>--> <!--/* </div>*/-->
<div class="auth-group" sec:authorize="isAnonymous()"> <div class="auth-group" sec:authorize="isAnonymous()">
<a th:href="@{/login}" class="login-btn login-btn-box">로그인</a> <a th:href="@{/login}" class="login-btn login-btn-box">로그인</a>
<a href="#" class="btn-signup">회원가입</a> <a th:href="@{/signup}" class="btn-signup">회원가입</a>
</div> </div>
<!-- Logout State (Authenticated) --> <!-- Logout State (Authenticated) -->
@@ -284,7 +284,7 @@
var FORCE_LOGOUT_URL = /*[[@{/login?forceLogout=true}]]*/ '/login?forceLogout=true'; var FORCE_LOGOUT_URL = /*[[@{/login?forceLogout=true}]]*/ '/login?forceLogout=true';
var LOGOUT_URL = /*[[@{/actionLogout.do}]]*/ '/actionLogout.do'; var LOGOUT_URL = /*[[@{/actionLogout.do}]]*/ '/actionLogout.do';
var timeoutMinutes = /*[[${sessionTimeoutMinutes}]]*/ 15; var timeoutMinutes = /*[[${sessionTimeoutMinutes}]]*/ 10;
var remainingSeconds = timeoutMinutes * 60; var remainingSeconds = timeoutMinutes * 60;
var WARNING_SECONDS = 60; // 만료 60초 전 연장 확인 모달 var WARNING_SECONDS = 60; // 만료 60초 전 연장 확인 모달
var POLL_INTERVAL_MS = 30000; // 서버 잔여시간 동기화 주기 var POLL_INTERVAL_MS = 30000; // 서버 잔여시간 동기화 주기
@@ -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> </container-descriptor>
<session-descriptor> <session-descriptor>
<timeout-secs>1800</timeout-secs> <!-- 세션 타임아웃 10분 고정 (application.yml server.servlet.session.timeout=10m 과 동일 값 유지) -->
<timeout-secs>600</timeout-secs>
<cookie-name>JSESSIONID_PORTAL</cookie-name> <cookie-name>JSESSIONID_PORTAL</cookie-name>
<persistent-store-type>replicated_if_clustered</persistent-store-type> <persistent-store-type>replicated_if_clustered</persistent-store-type>
</session-descriptor> </session-descriptor>