Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2106142d93 | |||
| 0f58f043a8 | |||
| 61be6bf108 | |||
| 96d6dd3df1 | |||
| f9b53810d9 | |||
| 41be827b81 | |||
| 78898ab5a8 | |||
| 19c17f4e91 | |||
| c2d84bb024 | |||
| 6434d48b05 | |||
| d5f0f3aab4 | |||
| dfc859c9cf | |||
| 24c669b0be | |||
| 3eedc522e2 | |||
| a361cc3dec | |||
| 1ac9fa5407 | |||
| 9981459691 | |||
| a2878f9cee | |||
| 1f10dda993 | |||
| a6807a37c2 | |||
| 6c10ae12c7 | |||
| 01c4a80182 | |||
| a5611cf775 | |||
| c733e6b200 | |||
| a2813f391c | |||
| f221c20ece | |||
| 4a3b5b43f3 | |||
| 8de0d94063 | |||
| 57d4efb4eb | |||
| c865359819 | |||
| 41391c8df0 | |||
| 9f4874fe44 | |||
| 43d915342e | |||
| ba00b80bfe | |||
| 87763ba325 | |||
| 5b7a4a108d | |||
| 9b9bcb5be4 | |||
| bdb249b74b | |||
| 93f7c210bc | |||
| bb79b55f08 | |||
| 23bd0c1207 | |||
| 28a9ff1a7f | |||
| 045f354e5c | |||
| 4f577c8c14 | |||
| cc4c1a6b53 | |||
| 551a2da717 | |||
| 1c57d348e6 | |||
| 2c29c8a466 | |||
| d2052d0e16 | |||
| 9c4f3cfb04 |
@@ -254,7 +254,8 @@ CREATE TABLE DVPOWN.PT_MESSAGE_RECIPIENT
|
||||
)
|
||||
;
|
||||
|
||||
create table DVPOWN.PT_TOKEN
|
||||
create table PT_TOKEN
|
||||
(
|
||||
(
|
||||
TOKEN VARCHAR2(255) not null
|
||||
primary key,
|
||||
|
||||
@@ -22,6 +22,10 @@ public class PortalApplication extends SpringBootServletInitializer {
|
||||
|
||||
private static final Logger portal_logger = LoggerFactory.getLogger(PortalApplication.class);
|
||||
|
||||
public PortalApplication() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
portal_logger.info("##### PortalApplication Start #####");
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
|
||||
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||
import com.eactive.apim.portal.common.exception.NotFoundException;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -68,6 +69,10 @@ public class ApiController {
|
||||
|
||||
@GetMapping("/testbed/api")
|
||||
public String testbedByApi(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
return "redirect:/login?reason=auth";
|
||||
}
|
||||
String selectedApiServiceName = "API 서비스 선택";
|
||||
String selectedServiceId = "";
|
||||
boolean idExists = false;
|
||||
@@ -93,6 +98,10 @@ public class ApiController {
|
||||
|
||||
@GetMapping("/testbed")
|
||||
public String testbedByApiService(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
return "redirect:/login?reason=auth";
|
||||
}
|
||||
String selectedApiServiceName = "API 서비스 선택";
|
||||
boolean idExists = false;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -637,14 +645,8 @@ public class MyAppController {
|
||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
||||
}
|
||||
|
||||
// API 선택 검증
|
||||
if (selectedApis == null || selectedApis.isEmpty()) {
|
||||
redirectAttributes.addFlashAttribute("error", "최소 1개 이상의 API를 선택해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/register/step2");
|
||||
}
|
||||
|
||||
// 선택된 API를 세션에 저장
|
||||
registration.setSelectedApis(selectedApis);
|
||||
// API 선택은 선택 사항 — 미선택(빈 목록)도 허용한다.
|
||||
registration.setSelectedApis(selectedApis != null ? selectedApis : new ArrayList<>());
|
||||
|
||||
// 등록이 완료되었는지 최종 검증
|
||||
if (!registration.isComplete()) {
|
||||
|
||||
@@ -99,6 +99,7 @@ public class ApiKeyRegistrationDTO implements Serializable {
|
||||
}
|
||||
|
||||
public boolean isComplete() {
|
||||
return isStep1Complete() && isStep2Complete();
|
||||
// API 선택은 선택 사항이므로 기본 정보(Step1)만 완료되면 등록 가능하다.
|
||||
return isStep1Complete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.eactive.apim.portal.apps.user.dto.PortalOrgDTO;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@@ -25,8 +24,7 @@ public class AppRequestDTO {
|
||||
|
||||
private ApprovalDTO approval;
|
||||
|
||||
@NotEmpty
|
||||
private String apiList = ""; //comma separated api id list
|
||||
private String apiList = ""; //comma separated api id list (미선택 허용)
|
||||
|
||||
private String apiGroupList = ""; //comma separated api group id list
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -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
|
||||
|
||||
+11
-19
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.session.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.session.entity.UserSession;
|
||||
import com.eactive.apim.portal.apps.session.repository.UserSessionRepository;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -20,8 +21,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";
|
||||
@@ -64,7 +66,8 @@ public class UserSessionService {
|
||||
.forceLogout("N")
|
||||
.build();
|
||||
userSessionRepository.save(session);
|
||||
log.info("세션 등록 - loginId: {}, sessionId: {}, ip: {}", loginId, sessionId, ipAddress);
|
||||
log.info("세션 등록 - loginId: {}, sessionId: {}, ip: {}",
|
||||
StringMaskingUtil.maskLoginId(loginId), StringMaskingUtil.maskToken(sessionId), StringMaskingUtil.maskIpAddress(ipAddress));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,7 +77,7 @@ public class UserSessionService {
|
||||
public void forceLogoutOtherSessions(String loginId, String currentSessionId) {
|
||||
int count = userSessionRepository.forceLogoutOtherSessions(loginId, currentSessionId);
|
||||
if (count > 0) {
|
||||
log.info("강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
|
||||
log.info("강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", StringMaskingUtil.maskLoginId(loginId), count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +89,7 @@ public class UserSessionService {
|
||||
public void forceLogoutAllSessions(String loginId) {
|
||||
int count = userSessionRepository.forceLogoutAllSessions(loginId);
|
||||
if (count > 0) {
|
||||
log.info("전체 강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
|
||||
log.info("전체 강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", StringMaskingUtil.maskLoginId(loginId), count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +110,7 @@ public class UserSessionService {
|
||||
public void removeSession(String sessionId) {
|
||||
if (userSessionRepository.existsById(sessionId)) {
|
||||
userSessionRepository.deleteById(sessionId);
|
||||
log.debug("세션 삭제 - sessionId: {}", sessionId);
|
||||
log.debug("세션 삭제 - sessionId: {}", StringMaskingUtil.maskToken(sessionId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,21 +123,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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,7 +51,7 @@ public class AccountController {
|
||||
private final UserSessionService userSessionService;
|
||||
|
||||
|
||||
@PostMapping("/confirm_password")
|
||||
@PostMapping("/password/confirm")
|
||||
public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword) {
|
||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||
boolean isPasswordCorrect = userFacade.verifyCurrentPassword(currentLoginId, inputPassword);
|
||||
@@ -60,19 +60,23 @@ public class AccountController {
|
||||
return ResponseEntity.ok(new ValidationResponse(isPasswordCorrect, message));
|
||||
}
|
||||
|
||||
@GetMapping("/change_password")
|
||||
public String showChangePasswordPage(Model model) {
|
||||
@GetMapping("/password/verify")
|
||||
public String showChangePasswordPage(Model model, HttpSession session) {
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
// ENFORCE 강제 상태면 변경 페이지에 "변경/로그아웃" 강제 팝업을 띄운다.
|
||||
if (Boolean.TRUE.equals(session.getAttribute("pwEnforce"))) {
|
||||
model.addAttribute("forcedPasswordReset", true);
|
||||
}
|
||||
return "apps/mypage/passwordChangeEntry";
|
||||
}
|
||||
|
||||
@GetMapping("/new_password")
|
||||
@GetMapping("/password/change")
|
||||
public String showNewPasswordPage(Model model) {
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
return "apps/mypage/passwordChange";
|
||||
}
|
||||
|
||||
@PostMapping("/verify_current_password")
|
||||
@PostMapping("/password/verify")
|
||||
public String verifyCurrentPassword(@RequestParam String currentPassword, RedirectAttributes redirectAttributes, HttpSession session, Model model) {
|
||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||
if (userFacade.verifyCurrentPassword(currentLoginId, currentPassword)) {
|
||||
@@ -80,11 +84,11 @@ public class AccountController {
|
||||
return "apps/mypage/passwordChange";
|
||||
} else {
|
||||
redirectAttributes.addFlashAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
|
||||
return "redirect:/change_password";
|
||||
return "redirect:/password/verify";
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/mypage/change_new_password")
|
||||
@PostMapping("/password/change")
|
||||
public String updatePassword(@RequestParam String newPassword,
|
||||
@RequestParam String confirmPassword,
|
||||
HttpSession session,
|
||||
@@ -96,10 +100,11 @@ public class AccountController {
|
||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||
userFacade.updatePassword(currentLoginId, newPassword, confirmPassword);
|
||||
|
||||
// 비밀번호 만료 관련 세션 속성 제거
|
||||
// 비밀번호 만료/강제 관련 세션 속성 제거
|
||||
session.removeAttribute("passwordExpired");
|
||||
session.removeAttribute("success");
|
||||
session.removeAttribute("redirectUrl");
|
||||
session.removeAttribute("pwEnforce");
|
||||
|
||||
// 세션 무효화 전에 DB 세션 레코드를 정리한다.
|
||||
// SecurityContextLogoutHandler 는 HTTP 세션만 invalidate 하고 UserSession DB 레코드는
|
||||
@@ -113,11 +118,16 @@ public class AccountController {
|
||||
redirectAttributes.addFlashAttribute("success", "비밀번호가 성공적으로 변경되었습니다.");
|
||||
return "redirect:/login";
|
||||
} catch (IllegalArgumentException e) {
|
||||
// 검증 실패(비밀번호 규칙/이력 등) — 사용자에게 안내, 스택은 불필요
|
||||
logger.warn("비밀번호 변경 검증 실패: {}", e.getMessage());
|
||||
model.addAttribute("error", e.getMessage());
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
return "apps/mypage/passwordChange";
|
||||
} catch (Exception e) {
|
||||
model.addAttribute("error", e.getMessage());
|
||||
// 예기치 못한 오류(트랜잭션 롤백 등) — 원인 추적을 위해 스택은 남기되,
|
||||
// 사용자에게는 시스템 예외 메시지를 노출하지 않고 일반 안내만 보여준다.
|
||||
logger.error("비밀번호 변경 처리 중 오류", e);
|
||||
model.addAttribute("error", "비밀번호 변경 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
return "apps/mypage/passwordChange";
|
||||
}
|
||||
|
||||
+2
-2
@@ -74,11 +74,11 @@ public class UserManRestController {
|
||||
return new ResponseDTO(200, "SUCCESS", "변경되었습니다. 확인 버튼을 누르시면 계정을 로그아웃 합니다.");
|
||||
}
|
||||
|
||||
// 관리자 -> 이용자 변경 (본인 제외)
|
||||
// 관리자 -> 개발자 변경 (본인 제외)
|
||||
@PostMapping("/revoke-manager")
|
||||
public ResponseDTO revokeManager(@RequestBody PortalUserDTO user) {
|
||||
userManFacade.revokeManager(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
|
||||
return new ResponseDTO(200, "SUCCESS", "이용자로 변경되었습니다.");
|
||||
return new ResponseDTO(200, "SUCCESS", "개발자로 변경되었습니다.");
|
||||
}
|
||||
|
||||
// 소속 제외 -> 개인이용자로 전환
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.eactive.apim.portal.apps.user.dto;
|
||||
import com.eactive.apim.portal.common.validator.AuthNumberMatch;
|
||||
import com.eactive.apim.portal.common.validator.CellPhone;
|
||||
import com.eactive.apim.portal.common.validator.PasswordMatch;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleForDjbank;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRule;
|
||||
import lombok.Data;
|
||||
import org.hibernate.validator.constraints.Length;
|
||||
import org.hibernate.validator.constraints.NotEmpty;
|
||||
@@ -12,7 +12,7 @@ import org.hibernate.validator.constraints.NotEmpty;
|
||||
@AuthNumberMatch(recipient = "loginId", authField = "authNumber")
|
||||
@PasswordMatch(input = "password", confirm = "password2")
|
||||
@Data
|
||||
@PasswordRuleForDjbank(password = "password", loginId = "loginId", mobile = "mobileNumber")
|
||||
@PasswordRule(password = "password", loginId = "loginId", mobile = "mobileNumber")
|
||||
public class PortalUserRegistrationDTO {
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.user.dto;
|
||||
|
||||
import com.eactive.apim.portal.common.validator.PasswordMatch;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleForDjbank;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRule;
|
||||
import com.eactive.apim.portal.common.validator.UniqueId;
|
||||
import com.eactive.apim.portal.portaluser.entity.UserStatus;
|
||||
import lombok.Data;
|
||||
@@ -13,7 +13,7 @@ import java.io.Serializable;
|
||||
|
||||
@PasswordMatch(input = "password", confirm = "password2")
|
||||
@Data
|
||||
@PasswordRuleForDjbank(loginId = "userId", password = "password", mobile = "mobilePhone")
|
||||
@PasswordRule(loginId = "userId", password = "password", mobile = "mobilePhone")
|
||||
public class UserRegisterDTO implements Serializable {
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.user.facade;
|
||||
|
||||
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
|
||||
import com.eactive.apim.portal.apps.user.service.PasswordService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalOrgService;
|
||||
@@ -71,7 +72,7 @@ public class UserFacadeImpl implements UserFacade {
|
||||
updateUserBasicInfo(user, portalUserDTO);
|
||||
portalUserService.updateUser(user);
|
||||
|
||||
log.info("사용자 정보 업데이트 완료: {}", user.getLoginId());
|
||||
log.info("사용자 정보 업데이트 완료: {}", StringMaskingUtil.maskLoginId(user.getLoginId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -98,7 +99,7 @@ public class UserFacadeImpl implements UserFacade {
|
||||
portalUserService.updateUser(user);
|
||||
portalOrgService.updateOrg(org);
|
||||
|
||||
log.info("법인 관리자 정보 업데이트 완료: {}", user.getLoginId());
|
||||
log.info("법인 관리자 정보 업데이트 완료: {}", StringMaskingUtil.maskLoginId(user.getLoginId()));
|
||||
}
|
||||
|
||||
// 사용자 기본 업데이트
|
||||
@@ -136,7 +137,7 @@ public class UserFacadeImpl implements UserFacade {
|
||||
// 법인 관리자 탈퇴 제한
|
||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER) {
|
||||
if(portalUserService.checkOrgHasOtherUsers(user.getPortalOrg())){
|
||||
throw new IllegalArgumentException("법인 관리자권한을 다른 이용자에게 위임하신 후 탈퇴가 가능합니다.");
|
||||
throw new IllegalArgumentException("법인 관리자권한을 다른 개발자에게 위임하신 후 탈퇴가 가능합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +148,7 @@ public class UserFacadeImpl implements UserFacade {
|
||||
messageRequestFacade.deleteUserMessage(user.getUserName(),user.getLoginId());
|
||||
|
||||
portalUserService.deleteUser(user);
|
||||
log.info("회원 탈퇴 처리 완료: {}", user.getLoginId());
|
||||
log.info("회원 탈퇴 처리 완료: {}", StringMaskingUtil.maskLoginId(user.getLoginId()));
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트 유효성 확인
|
||||
@@ -215,6 +216,6 @@ public class UserFacadeImpl implements UserFacade {
|
||||
user.setUserStatus(PortalUserEnums.UserStatus.ACTIVE);
|
||||
portalUserService.save(user);
|
||||
|
||||
log.info("사용자 이메일 인증 완료 - 활성화: {}", email);
|
||||
log.info("사용자 이메일 인증 완료 - 활성화: {}", StringMaskingUtil.maskEmail(email));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.UserStatus;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||
import com.eactive.apim.portal.apps.user.service.UserRoleHistoryService;
|
||||
import com.eactive.apim.portal.apps.user.service.UserRoleHistoryService.ChangeType;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||
@@ -59,6 +61,7 @@ public class UserManFacade {
|
||||
private final CellPhoneValidator cellPhoneValidator;
|
||||
private final UserSessionService userSessionService;
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
private final UserRoleHistoryService userRoleHistoryService;
|
||||
|
||||
public Page<PortalUserDTO> getUsers(PortalOrgDTO userOrg, Pageable pageable) {
|
||||
return portalUserRepository
|
||||
@@ -134,7 +137,7 @@ public class UserManFacade {
|
||||
if (existingUser.isPresent()) {
|
||||
PortalUser user = existingUser.get();
|
||||
if (user.getPortalOrg() != null && !user.getRoleCode().equals(RoleCode.ROLE_USER)) {
|
||||
throw new IllegalArgumentException("기관에 등록된 이용자 입니다.");
|
||||
throw new IllegalArgumentException("기관에 등록된 개발자 입니다.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +243,9 @@ public class UserManFacade {
|
||||
PortalUser currentUser = portalUserRepository.findById(portalAuthenticatedUser.getId())
|
||||
.orElseThrow(() -> new NotFoundException("사용자를 찾을 수 없습니다. "));
|
||||
|
||||
RoleCode targetBefore = targetUser.getRoleCode();
|
||||
RoleCode currentBefore = currentUser.getRoleCode();
|
||||
|
||||
// 3. Assign role code ROLE_CORP_MANAGER to target User
|
||||
targetUser.setRoleCode(RoleCode.ROLE_CORP_MANAGER);
|
||||
|
||||
@@ -249,6 +255,10 @@ public class UserManFacade {
|
||||
portalUserRepository.save(targetUser);
|
||||
portalUserRepository.save(currentUser);
|
||||
|
||||
// 역할 변경 감사 이력: 대상 위임 + 본인 회수
|
||||
userRoleHistoryService.record(targetUser.getLoginId(), targetBefore, RoleCode.ROLE_CORP_MANAGER, ChangeType.MANAGER_ASSIGN);
|
||||
userRoleHistoryService.record(currentUser.getLoginId(), currentBefore, RoleCode.ROLE_CORP_USER, ChangeType.MANAGER_REVOKE);
|
||||
|
||||
// 권한 변경 즉시 반영: 대상(타 세션)은 강제 로그아웃, 본인(현재 세션)은 in-place 재인증
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
portalUserAuthService.reloadCurrentAuthentication();
|
||||
@@ -264,7 +274,7 @@ public class UserManFacade {
|
||||
}
|
||||
|
||||
/**
|
||||
* 법인 관리자 권한 회수 (관리자 → 이용자).
|
||||
* 법인 관리자 권한 회수 (관리자 → 개발자).
|
||||
* 대상은 본인이 아닌 같은 기관의 ROLE_CORP_MANAGER 여야 한다.
|
||||
*/
|
||||
public void revokeManager(PortalAuthenticatedUser admin, String targetUserId) {
|
||||
@@ -279,12 +289,16 @@ public class UserManFacade {
|
||||
throw new IllegalArgumentException("본인의 권한은 변경할 수 없습니다.");
|
||||
}
|
||||
if (targetUser.getRoleCode() != RoleCode.ROLE_CORP_MANAGER) {
|
||||
throw new IllegalArgumentException("관리자만 이용자로 변경할 수 있습니다.");
|
||||
throw new IllegalArgumentException("관리자만 개발자로 변경할 수 있습니다.");
|
||||
}
|
||||
|
||||
RoleCode revokeBefore = targetUser.getRoleCode();
|
||||
targetUser.setRoleCode(RoleCode.ROLE_CORP_USER);
|
||||
portalUserRepository.save(targetUser);
|
||||
|
||||
// 역할 변경 감사 이력: 관리자 → 개발자
|
||||
userRoleHistoryService.record(targetUser.getLoginId(), revokeBefore, RoleCode.ROLE_CORP_USER, ChangeType.MANAGER_REVOKE);
|
||||
|
||||
// 권한 회수를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
}
|
||||
@@ -305,10 +319,14 @@ public class UserManFacade {
|
||||
throw new IllegalArgumentException("본인은 소속에서 제외할 수 없습니다.");
|
||||
}
|
||||
|
||||
RoleCode removeBefore = targetUser.getRoleCode();
|
||||
targetUser.setPortalOrg(null);
|
||||
targetUser.setRoleCode(RoleCode.ROLE_USER);
|
||||
portalUserRepository.save(targetUser);
|
||||
|
||||
// 역할 변경 감사 이력: 소속 제외 → 개인 전환
|
||||
userRoleHistoryService.record(targetUser.getLoginId(), removeBefore, RoleCode.ROLE_USER, ChangeType.ORG_REMOVE);
|
||||
|
||||
// 소속 제외를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
@Override
|
||||
public ValidationResponse checkPassword(String password, String loginId, String mobileNumber) {
|
||||
boolean isValid = passwordValidator.isValidPassword(password, loginId, mobileNumber);
|
||||
String message = isValid ? "유효한 비밀번호입니다." : "비밀번호는 영문/숫자/특수문자 포함 8~20자, 로그인 아이디, 휴대폰 번호, 3자리 이상 연속, 반복 문자 사용 불가능 합니다.";
|
||||
String message = isValid ? "유효한 비밀번호입니다." : "비밀번호는 영문/숫자/특수문자 포함 8~50자, 로그인 아이디, 휴대폰 번호, 3자리 이상 연속, 반복 문자 사용 불가능 합니다.";
|
||||
return new ValidationResponse(isValid, message);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ public class PasswordService {
|
||||
// 새 비밀번호 설정
|
||||
String newPasswordHash = passwordEncoder.encode(newPassword);
|
||||
user.setPasswordHash(newPasswordHash);
|
||||
// 변경일 기록 → 재설정 강제(null 트리거) 해제
|
||||
user.setPasswordChangeDate(LocalDateTime.now());
|
||||
portalUserRepository.save(user);
|
||||
|
||||
savePasswordHistory(user.getId(), newPasswordHash);
|
||||
|
||||
@@ -184,6 +184,8 @@ public class PortalUserService {
|
||||
user.setPasswordHash(passwordEncoder.encode(dto.getPassword()));
|
||||
user.setMobileNumber(dto.getMobileNumber());
|
||||
user.setEmailAddr(normalizedEmail);
|
||||
// 가입 시점을 비밀번호 변경일로 기록 → 신규 가입자는 재설정 강제 대상에서 제외된다.
|
||||
user.setPasswordChangeDate(java.time.LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.eactive.apim.portal.apps.user.service;
|
||||
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import com.eactive.apim.portal.portaluser.entity.UserRoleHistory;
|
||||
import com.eactive.apim.portal.portaluser.repository.UserRoleHistoryRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 사용자 역할(권한) 변경 감사 이력 기록 서비스.
|
||||
*
|
||||
* <p>법인 관리자 위임/회수, 소속 제외 등 역할 변경 이벤트를 {@code ptl_user_role_history} 에 남긴다.
|
||||
* 변경 수행자(changedBy)는 현재 인증된 관리자의 loginId 로 기록한다.</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserRoleHistoryService {
|
||||
|
||||
/** 역할 변경 유형. */
|
||||
public enum ChangeType {
|
||||
MANAGER_ASSIGN, // 관리자 권한 위임
|
||||
MANAGER_REVOKE, // 관리자 권한 회수
|
||||
ORG_REMOVE // 소속 제외(개인으로 전환)
|
||||
}
|
||||
|
||||
private final UserRoleHistoryRepository userRoleHistoryRepository;
|
||||
|
||||
/**
|
||||
* 역할 변경 이력을 기록한다. 감사 목적이므로 실패해도 본 트랜잭션을 롤백시키지 않도록
|
||||
* 호출부에서 예외를 전파하지 않는다(내부에서 로깅만).
|
||||
*/
|
||||
@Transactional
|
||||
public void record(String targetLoginId, RoleCode before, RoleCode after, ChangeType changeType) {
|
||||
try {
|
||||
String actor = SecurityUtil.getCurrentLoginId();
|
||||
if (actor == null || actor.isEmpty()) {
|
||||
actor = "SYSTEM";
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
UserRoleHistory history = new UserRoleHistory();
|
||||
history.setUserId(targetLoginId);
|
||||
history.setBeforeRole(before != null ? before.name() : null);
|
||||
history.setAfterRole(after != null ? after.name() : null);
|
||||
history.setChangeType(changeType.name());
|
||||
history.setChangedBy(actor);
|
||||
history.setChangeDate(now);
|
||||
history.setCreatedBy(actor);
|
||||
history.setCreatedDate(now);
|
||||
|
||||
userRoleHistoryRepository.save(history);
|
||||
} catch (Exception e) {
|
||||
log.error("역할 변경 이력 기록 실패 - target: {}, type: {}", targetLoginId, changeType, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.eactive.apim.portal.apps.user.validator;
|
||||
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleForKbankValidator;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleForDjbankValidator;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleValidator;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@@ -17,13 +16,13 @@ public class PasswordValidator {
|
||||
}
|
||||
|
||||
public boolean isValidPassword(String password, String loginId, String mobileNumber) {
|
||||
PasswordRuleForDjbankValidator validator = new PasswordRuleForDjbankValidator();
|
||||
PasswordRuleValidator validator = new PasswordRuleValidator();
|
||||
return validator.isValid(password, loginId, mobileNumber);
|
||||
}
|
||||
|
||||
private boolean isValidLengthAndCharacters(String password) {
|
||||
final int MIN = 8;
|
||||
final int MAX = 20;
|
||||
final int MAX = 50;
|
||||
final String REGEX = "^(?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "}$";
|
||||
return password.matches(REGEX);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ public class GlobalControllerAdvice {
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 세션 타이머 기준이 되는 타임아웃(분). PortalProperty(Portal/session.timeout.minutes)에서 조회.
|
||||
* 화면 세션 타이머 기준이 되는 타임아웃(분). 10분 고정 (UserSessionService.SESSION_TIMEOUT_MINUTES).
|
||||
*/
|
||||
@ModelAttribute("sessionTimeoutMinutes")
|
||||
public int sessionTimeoutMinutes() {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package com.eactive.apim.portal.common.dto;
|
||||
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleForKbank;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRule;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@PasswordRuleForKbank(password = "password", loginId = "loginId", mobile = "mobile")
|
||||
@PasswordRule(password = "password", loginId = "loginId", mobile = "mobile")
|
||||
public class PasswordValidationDTO {
|
||||
private String password;
|
||||
private String loginId;
|
||||
|
||||
+12
-2
@@ -7,6 +7,8 @@ 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.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.file.exception.InvalidFileException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -49,12 +51,20 @@ public class PortalGlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(value = UserNotLoginException.class)
|
||||
public ModelAndView handleUserNotLoginException(HttpServletRequest request, UserNotLoginException ex) {
|
||||
return new ModelAndView("redirect:/login");
|
||||
return new ModelAndView("redirect:/login?reason=auth");
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = AccessDeniedException.class)
|
||||
public ModelAndView handleAccessDeniedException(HttpServletRequest request, AccessDeniedException ex) {
|
||||
return new ModelAndView("redirect:/login");
|
||||
// 미로그인 사용자는 로그인 페이지로 유도, 로그인 상태에서의 권한 부족은 오류 안내 페이지로 표시한다.
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
return new ModelAndView("redirect:/login?reason=auth");
|
||||
}
|
||||
log.warn("접근 권한 없음: loginId={}, uri={}", StringMaskingUtil.maskLoginId(SecurityUtil.getCurrentLoginId()), request.getRequestURI());
|
||||
ModelAndView modelAndView = new ModelAndView("error");
|
||||
modelAndView.addObject("errorTitle", "페이지 접근 권한이 없습니다.");
|
||||
modelAndView.addObject("errorDescription", "해당 페이지를 이용할 수 있는 권한이 없는 계정입니다.\n권한이 필요한 경우 관리자에게 문의해 주세요.");
|
||||
return modelAndView;
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = PortalRedirectException.class)
|
||||
|
||||
+3
-1
@@ -1,5 +1,6 @@
|
||||
package com.eactive.apim.portal.common.migration;
|
||||
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
@@ -177,7 +178,8 @@ public class LegacyEncryptionMigrationController {
|
||||
|| "::1".equals(remote);
|
||||
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
||||
if (!localAddr || viaProxy) {
|
||||
log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}", remote, request.getHeader("X-Forwarded-For"));
|
||||
log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}",
|
||||
StringMaskingUtil.maskIpAddress(remote), StringMaskingUtil.maskIpAddress(request.getHeader("X-Forwarded-For")));
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "localhost(127.0.0.1) 직접 호출만 허용됩니다.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,98 @@ public class StringMaskingUtil {
|
||||
return number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 ID 마스킹 처리 (로그 출력용).
|
||||
* 이메일 형식이면 {@link #maskEmail(String)} 규칙을, 그 외(스태프 등 비이메일 ID)는 부분 마스킹을 적용한다.
|
||||
*/
|
||||
public static String maskLoginId(String loginId) {
|
||||
if (!isValidString(loginId)) {
|
||||
return loginId;
|
||||
}
|
||||
if (loginId.contains("@")) {
|
||||
return maskEmail(loginId);
|
||||
}
|
||||
int len = loginId.length();
|
||||
if (len <= 2) {
|
||||
return stars(len);
|
||||
}
|
||||
if (len <= 4) {
|
||||
return loginId.charAt(0) + stars(len - 1);
|
||||
}
|
||||
// 5자 이상: 앞2 + 마스킹 + 뒤1
|
||||
return loginId.substring(0, 2) + stars(len - 3) + loginId.charAt(len - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* IP 주소 마스킹 처리 (로그 출력용). 첫 옥텟만 남기고 나머지를 마스킹한다.
|
||||
* <pre>127.0.0.1 → 127.***.***.***</pre>
|
||||
* IPv4 형식이 아니면 원본을 반환한다(멱등).
|
||||
*/
|
||||
public static String maskIpAddress(String ip) {
|
||||
if (!isValidString(ip)) {
|
||||
return ip;
|
||||
}
|
||||
String[] parts = ip.split("\\.");
|
||||
if (parts.length != 4) {
|
||||
return ip;
|
||||
}
|
||||
return parts[0] + ".***.***.***";
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰/세션ID 등 식별자 마스킹 처리 (로그 출력용). 앞 4자 + {@code ***} + 뒤 4자만 노출한다.
|
||||
* 길이가 짧으면(9자 미만) 전체를 마스킹한다.
|
||||
*/
|
||||
public static String maskToken(String token) {
|
||||
if (!isValidString(token)) {
|
||||
return token;
|
||||
}
|
||||
int len = token.length();
|
||||
if (len < 9) {
|
||||
return stars(len);
|
||||
}
|
||||
return token.substring(0, 4) + "***" + token.substring(len - 4);
|
||||
}
|
||||
|
||||
// 로그에 값을 그대로 남기면 안 되는 민감 헤더(소문자 비교)
|
||||
private static final java.util.Set<String> SENSITIVE_HEADERS = new java.util.HashSet<>(Arrays.asList(
|
||||
"cookie", "set-cookie", "authorization", "proxy-authorization",
|
||||
"x-auth-token", "x-csrf-token", "x-xsrf-token", "x-api-key"));
|
||||
|
||||
private static final String REDACTED = "***REDACTED***";
|
||||
|
||||
/**
|
||||
* 요청/응답 헤더 로깅 시 민감 헤더(Cookie/Authorization 등)의 값을 {@code ***REDACTED***} 로 치환한다.
|
||||
* 그 외 헤더는 원본 값을 반환한다.
|
||||
*/
|
||||
public static String maskHeaderValue(String headerName, String headerValue) {
|
||||
if (headerName != null && SENSITIVE_HEADERS.contains(headerName.toLowerCase())) {
|
||||
return REDACTED;
|
||||
}
|
||||
return headerValue;
|
||||
}
|
||||
|
||||
// 세션 속성 이름에 포함되면 값을 리댁트할 키워드(소문자 부분일치)
|
||||
private static final String[] SENSITIVE_ATTRIBUTE_KEYWORDS = {
|
||||
"token", "secret", "password", "credential", "csrf",
|
||||
"security_context", "loginid", "authentication"};
|
||||
|
||||
/**
|
||||
* 세션 속성 로깅 시 민감 속성(SPRING_SECURITY_CONTEXT, token, loginId 등)의 값을 리댁트한다.
|
||||
* 속성 이름에 민감 키워드가 부분일치하면 {@code ***REDACTED***} 를 반환한다.
|
||||
*/
|
||||
public static String maskAttributeValue(String attrName, String value) {
|
||||
if (attrName != null) {
|
||||
String lower = attrName.toLowerCase();
|
||||
for (String kw : SENSITIVE_ATTRIBUTE_KEYWORDS) {
|
||||
if (lower.contains(kw)) {
|
||||
return REDACTED;
|
||||
}
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// 기존 메서드 오버로딩 (하위 호환성)
|
||||
public static String maskName(String name) {
|
||||
return maskName(name, null, null);
|
||||
|
||||
@@ -5,14 +5,20 @@ import javax.validation.Payload;
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Constraint(validatedBy = PasswordRuleValidator.class)
|
||||
@Target({ElementType.FIELD})
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface PasswordRule {
|
||||
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 3자리 이상 연속,반복 문자 불가)";
|
||||
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~50자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
|
||||
String password();
|
||||
|
||||
String loginId();
|
||||
|
||||
String mobile();
|
||||
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import javax.validation.Constraint;
|
||||
import javax.validation.Payload;
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Constraint(validatedBy = PasswordRuleForDjbankValidator.class)
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface PasswordRuleForDjbank {
|
||||
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
|
||||
String password();
|
||||
|
||||
String loginId();
|
||||
|
||||
String mobile();
|
||||
|
||||
}
|
||||
-144
@@ -1,144 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import org.apache.commons.beanutils.PropertyUtils;
|
||||
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Created by Sungpil Hyun
|
||||
*/
|
||||
public class PasswordRuleForDjbankValidator implements ConstraintValidator<PasswordRuleForDjbank, Object> {
|
||||
|
||||
// 최소 8자, 최대 20자 상수 선언
|
||||
private static final int MIN = 8;
|
||||
private static final int MAX = 20;
|
||||
|
||||
private String password;
|
||||
private String loginId;
|
||||
private String mobileNumber;
|
||||
|
||||
// 3자리 연속 문자 정규식
|
||||
private static final String SAMEPT = "(\\w)\\1\\1";
|
||||
// 공백 문자 정규식
|
||||
private static final String BLANKPT = "(\\s)";
|
||||
|
||||
@Override
|
||||
public void initialize(PasswordRuleForDjbank constraintAnnotation) {
|
||||
this.password = constraintAnnotation.password();
|
||||
this.loginId = constraintAnnotation.loginId();
|
||||
this.mobileNumber = constraintAnnotation.mobile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
|
||||
|
||||
String passwordValue = null;
|
||||
String loginIdValue = null;
|
||||
String mobileNumberValue = null;
|
||||
|
||||
try {
|
||||
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
|
||||
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
|
||||
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isValid(passwordValue, loginIdValue, mobileNumberValue);
|
||||
}
|
||||
|
||||
public boolean isValid(String password, String loginId, String mobileNumber) {
|
||||
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
|
||||
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
|
||||
|
||||
// 정규식 검사객체
|
||||
Matcher matcher;
|
||||
|
||||
// 공백 체크
|
||||
if (password == null || "".equals(password)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ASCII 문자 비교를 위한 UpperCase
|
||||
String tmpPw = password.toUpperCase();
|
||||
// 문자열 길이
|
||||
int strLen = tmpPw.length();
|
||||
|
||||
// 글자 길이 체크
|
||||
if (strLen > 20 || strLen < 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loginId != null && !loginId.isEmpty()) {
|
||||
String[] loginParts = loginId.split("@");
|
||||
if (loginParts.length > 0) {
|
||||
String username = loginParts[0].toUpperCase();
|
||||
if (tmpPw.contains(username)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile number validation
|
||||
if (mobileNumber != null && !mobileNumber.isEmpty()) {
|
||||
String[] mobileParts = mobileNumber.split("-");
|
||||
for (String part : mobileParts) {
|
||||
if (!part.isEmpty() && tmpPw.contains(part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공백 체크
|
||||
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 비밀번호 정규식 체크
|
||||
matcher = Pattern.compile(REGEX).matcher(tmpPw);
|
||||
if (!matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 동일한 문자 3개 이상 체크
|
||||
matcher = Pattern.compile(SAMEPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 연속된 문자 / 숫자 3개 이상 체크
|
||||
// ASCII Char를 담을 배열 선언
|
||||
int[] tmpArray = new int[strLen];
|
||||
|
||||
// Make Array
|
||||
for (int i = 0; i < strLen; i++) {
|
||||
tmpArray[i] = tmpPw.charAt(i);
|
||||
}
|
||||
|
||||
// Validation Array
|
||||
for (int i = 0; i < strLen - 2; i++) {
|
||||
if (isContinuous(tmpArray[i], tmpArray[i + 2]) && isContinuous(tmpArray[i], tmpArray[i + 1], tmpArray[i + 2])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Validation Complete
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
static boolean isContinuous(int first, int third) {
|
||||
// 첫 글자 A-Z / 0-9
|
||||
return (first > 47 && third < 58) || (first > 64 && third < 91);
|
||||
}
|
||||
|
||||
static boolean isContinuous(int first, int second, int third) {
|
||||
// 배열의 연속된 수 검사
|
||||
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
|
||||
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
import javax.validation.Constraint;
|
||||
import javax.validation.Payload;
|
||||
|
||||
@Constraint(validatedBy = PasswordRuleForKbankValidator.class)
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface PasswordRuleForKbank {
|
||||
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
|
||||
String password();
|
||||
|
||||
String loginId();
|
||||
|
||||
String mobile();
|
||||
|
||||
}
|
||||
-144
@@ -1,144 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
import org.apache.commons.beanutils.PropertyUtils;
|
||||
|
||||
/**
|
||||
* Created by Sungpil Hyun
|
||||
*/
|
||||
public class PasswordRuleForKbankValidator implements ConstraintValidator<PasswordRuleForKbank, Object> {
|
||||
|
||||
// 최소 8자, 최대 20자 상수 선언
|
||||
private static final int MIN = 8;
|
||||
private static final int MAX = 20;
|
||||
|
||||
private String password;
|
||||
private String loginId;
|
||||
private String mobileNumber;
|
||||
|
||||
// 3자리 연속 문자 정규식
|
||||
private static final String SAMEPT = "(\\w)\\1\\1";
|
||||
// 공백 문자 정규식
|
||||
private static final String BLANKPT = "(\\s)";
|
||||
|
||||
@Override
|
||||
public void initialize(PasswordRuleForKbank constraintAnnotation) {
|
||||
this.password = constraintAnnotation.password();
|
||||
this.loginId = constraintAnnotation.loginId();
|
||||
this.mobileNumber = constraintAnnotation.mobile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
|
||||
|
||||
String passwordValue = null;
|
||||
String loginIdValue = null;
|
||||
String mobileNumberValue = null;
|
||||
|
||||
try {
|
||||
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
|
||||
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
|
||||
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isValid(passwordValue, loginIdValue, mobileNumberValue);
|
||||
}
|
||||
|
||||
public boolean isValid(String password, String loginId, String mobileNumber) {
|
||||
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
|
||||
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
|
||||
|
||||
// 정규식 검사객체
|
||||
Matcher matcher;
|
||||
|
||||
// 공백 체크
|
||||
if (password == null || "".equals(password)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ASCII 문자 비교를 위한 UpperCase
|
||||
String tmpPw = password.toUpperCase();
|
||||
// 문자열 길이
|
||||
int strLen = tmpPw.length();
|
||||
|
||||
// 글자 길이 체크
|
||||
if (strLen > 20 || strLen < 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loginId != null && !loginId.isEmpty()) {
|
||||
String[] loginParts = loginId.split("@");
|
||||
if (loginParts.length > 0) {
|
||||
String username = loginParts[0].toUpperCase();
|
||||
if (tmpPw.contains(username)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile number validation
|
||||
if (mobileNumber != null && !mobileNumber.isEmpty()) {
|
||||
String[] mobileParts = mobileNumber.split("-");
|
||||
for (String part : mobileParts) {
|
||||
if (!part.isEmpty() && tmpPw.contains(part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공백 체크
|
||||
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 비밀번호 정규식 체크
|
||||
matcher = Pattern.compile(REGEX).matcher(tmpPw);
|
||||
if (!matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 동일한 문자 3개 이상 체크
|
||||
matcher = Pattern.compile(SAMEPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 연속된 문자 / 숫자 3개 이상 체크
|
||||
// ASCII Char를 담을 배열 선언
|
||||
int[] tmpArray = new int[strLen];
|
||||
|
||||
// Make Array
|
||||
for (int i = 0; i < strLen; i++) {
|
||||
tmpArray[i] = tmpPw.charAt(i);
|
||||
}
|
||||
|
||||
// Validation Array
|
||||
for (int i = 0; i < strLen - 2; i++) {
|
||||
if (isContinuous(tmpArray[i], tmpArray[i + 2]) && isContinuous(tmpArray[i], tmpArray[i + 1], tmpArray[i + 2])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Validation Complete
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
static boolean isContinuous(int first, int third) {
|
||||
// 첫 글자 A-Z / 0-9
|
||||
return (first > 47 && third < 58) || (first > 64 && third < 91);
|
||||
}
|
||||
|
||||
static boolean isContinuous(int first, int second, int third) {
|
||||
// 배열의 연속된 수 검사
|
||||
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
|
||||
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import javax.validation.Constraint;
|
||||
import javax.validation.Payload;
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Constraint(validatedBy = PasswordRuleForDjbankValidator.class)
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface PasswordRuleForKjbank {
|
||||
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
|
||||
String password();
|
||||
|
||||
String loginId();
|
||||
|
||||
String mobile();
|
||||
|
||||
}
|
||||
-144
@@ -1,144 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import org.apache.commons.beanutils.PropertyUtils;
|
||||
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Created by Sungpil Hyun
|
||||
*/
|
||||
public class PasswordRuleForKjbankValidator implements ConstraintValidator<PasswordRuleForKjbank, Object> {
|
||||
|
||||
// 최소 8자, 최대 20자 상수 선언
|
||||
private static final int MIN = 8;
|
||||
private static final int MAX = 20;
|
||||
|
||||
private String password;
|
||||
private String loginId;
|
||||
private String mobileNumber;
|
||||
|
||||
// 3자리 연속 문자 정규식
|
||||
private static final String SAMEPT = "(\\w)\\1\\1";
|
||||
// 공백 문자 정규식
|
||||
private static final String BLANKPT = "(\\s)";
|
||||
|
||||
@Override
|
||||
public void initialize(PasswordRuleForKjbank constraintAnnotation) {
|
||||
this.password = constraintAnnotation.password();
|
||||
this.loginId = constraintAnnotation.loginId();
|
||||
this.mobileNumber = constraintAnnotation.mobile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
|
||||
|
||||
String passwordValue = null;
|
||||
String loginIdValue = null;
|
||||
String mobileNumberValue = null;
|
||||
|
||||
try {
|
||||
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
|
||||
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
|
||||
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isValid(passwordValue, loginIdValue, mobileNumberValue);
|
||||
}
|
||||
|
||||
public boolean isValid(String password, String loginId, String mobileNumber) {
|
||||
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
|
||||
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
|
||||
|
||||
// 정규식 검사객체
|
||||
Matcher matcher;
|
||||
|
||||
// 공백 체크
|
||||
if (password == null || "".equals(password)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ASCII 문자 비교를 위한 UpperCase
|
||||
String tmpPw = password.toUpperCase();
|
||||
// 문자열 길이
|
||||
int strLen = tmpPw.length();
|
||||
|
||||
// 글자 길이 체크
|
||||
if (strLen > 20 || strLen < 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loginId != null && !loginId.isEmpty()) {
|
||||
String[] loginParts = loginId.split("@");
|
||||
if (loginParts.length > 0) {
|
||||
String username = loginParts[0].toUpperCase();
|
||||
if (tmpPw.contains(username)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile number validation
|
||||
if (mobileNumber != null && !mobileNumber.isEmpty()) {
|
||||
String[] mobileParts = mobileNumber.split("-");
|
||||
for (String part : mobileParts) {
|
||||
if (!part.isEmpty() && tmpPw.contains(part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공백 체크
|
||||
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 비밀번호 정규식 체크
|
||||
matcher = Pattern.compile(REGEX).matcher(tmpPw);
|
||||
if (!matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 동일한 문자 3개 이상 체크
|
||||
matcher = Pattern.compile(SAMEPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 연속된 문자 / 숫자 3개 이상 체크
|
||||
// ASCII Char를 담을 배열 선언
|
||||
int[] tmpArray = new int[strLen];
|
||||
|
||||
// Make Array
|
||||
for (int i = 0; i < strLen; i++) {
|
||||
tmpArray[i] = tmpPw.charAt(i);
|
||||
}
|
||||
|
||||
// Validation Array
|
||||
for (int i = 0; i < strLen - 2; i++) {
|
||||
if (isContinuous(tmpArray[i], tmpArray[i + 2]) && isContinuous(tmpArray[i], tmpArray[i + 1], tmpArray[i + 2])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Validation Complete
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
static boolean isContinuous(int first, int third) {
|
||||
// 첫 글자 A-Z / 0-9
|
||||
return (first > 47 && third < 58) || (first > 64 && third < 91);
|
||||
}
|
||||
|
||||
static boolean isContinuous(int first, int second, int third) {
|
||||
// 배열의 연속된 수 검사
|
||||
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
|
||||
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import org.apache.commons.beanutils.PropertyUtils;
|
||||
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
import java.util.regex.Matcher;
|
||||
@@ -8,11 +10,15 @@ import java.util.regex.Pattern;
|
||||
/**
|
||||
* Created by Sungpil Hyun
|
||||
*/
|
||||
public class PasswordRuleValidator implements ConstraintValidator<PasswordRule, String> {
|
||||
public class PasswordRuleValidator implements ConstraintValidator<PasswordRule, Object> {
|
||||
|
||||
// 최소 8자, 최대 20자 상수 선언
|
||||
// 최소 8자, 최대 50자 상수 선언
|
||||
private static final int MIN = 8;
|
||||
private static final int MAX = 20;
|
||||
private static final int MAX = 50;
|
||||
|
||||
private String password;
|
||||
private String loginId;
|
||||
private String mobileNumber;
|
||||
|
||||
// 3자리 연속 문자 정규식
|
||||
private static final String SAMEPT = "(\\w)\\1\\1";
|
||||
@@ -21,11 +27,30 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
||||
|
||||
@Override
|
||||
public void initialize(PasswordRule constraintAnnotation) {
|
||||
this.password = constraintAnnotation.password();
|
||||
this.loginId = constraintAnnotation.loginId();
|
||||
this.mobileNumber = constraintAnnotation.mobile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(String password, ConstraintValidatorContext constraintValidatorContext) {
|
||||
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
|
||||
|
||||
String passwordValue = null;
|
||||
String loginIdValue = null;
|
||||
String mobileNumberValue = null;
|
||||
|
||||
try {
|
||||
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
|
||||
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
|
||||
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isValid(passwordValue, loginIdValue, mobileNumberValue);
|
||||
}
|
||||
|
||||
public boolean isValid(String password, String loginId, String mobileNumber) {
|
||||
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
|
||||
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
|
||||
|
||||
@@ -43,10 +68,30 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
||||
int strLen = tmpPw.length();
|
||||
|
||||
// 글자 길이 체크
|
||||
if (strLen > 20 || strLen < 8) {
|
||||
if (strLen > MAX || strLen < MIN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loginId != null && !loginId.isEmpty()) {
|
||||
String[] loginParts = loginId.split("@");
|
||||
if (loginParts.length > 0) {
|
||||
String username = loginParts[0].toUpperCase();
|
||||
if (tmpPw.contains(username)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile number validation
|
||||
if (mobileNumber != null && !mobileNumber.isEmpty()) {
|
||||
String[] mobileParts = mobileNumber.split("-");
|
||||
for (String part : mobileParts) {
|
||||
if (!part.isEmpty() && tmpPw.contains(part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공백 체크
|
||||
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
@@ -94,5 +139,4 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
||||
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
|
||||
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -85,9 +85,9 @@ public class BaseDatasourceConfiguration {
|
||||
persistenceUnit = "gateway";
|
||||
}
|
||||
|
||||
// 개발 환경용
|
||||
// 개발 환경용 - local 프로파일에서는 스키마 검증(ddl-auto) 해제
|
||||
if (env.matchesProfiles("local")) {
|
||||
properties.put("hibernate.hbm2ddl.auto", "validate");
|
||||
properties.put("hibernate.hbm2ddl.auto", "none");
|
||||
}
|
||||
|
||||
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 강제(ENFORCE) 가드.
|
||||
*
|
||||
* <p>로그인 시 {@code PortalAuthenticationSuccessHandler} 가 대상자(비밀번호 미변경/만료)에게
|
||||
* 세션 플래그 {@link #ENFORCE_SESSION_ATTR} 를 설정한다. 이 플래그가 있는 동안에는 비밀번호
|
||||
* 변경/검증/로그아웃 경로를 제외한 모든 요청을 변경 페이지로 리다이렉트하여 접근을 차단한다.
|
||||
* 비밀번호 변경 완료 시 플래그가 제거되어 정상 접근이 회복된다.</p>
|
||||
*
|
||||
* <p>정적 자원 경로는 {@code PortalConfigWebDispatcherServlet.addInterceptors} 의
|
||||
* excludePathPatterns 로 제외한다.</p>
|
||||
*/
|
||||
public class PasswordChangeEnforcementInterceptor implements HandlerInterceptor {
|
||||
|
||||
/** ENFORCE 대상 세션 플래그. 로그인 핸들러가 설정, 변경 완료 시 제거. */
|
||||
public static final String ENFORCE_SESSION_ATTR = "pwEnforce";
|
||||
|
||||
/** 강제 상태에서도 접근 허용하는 경로(화이트리스트) */
|
||||
private static final Set<String> ALLOWED_PATHS = new HashSet<>(Arrays.asList(
|
||||
"/password/verify", // 현재 비밀번호 입력(진입) + 검증(POST)
|
||||
"/password/change", // 새 비밀번호 폼(GET) + 실제 변경(POST)
|
||||
"/password/confirm", // 비밀번호 확인 AJAX
|
||||
"/actionLogout.do", // 로그아웃
|
||||
"/login",
|
||||
"/error", "/403", "/404"
|
||||
));
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session == null || !Boolean.TRUE.equals(session.getAttribute(ENFORCE_SESSION_ATTR))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// AJAX/API 등 비(非)페이지 요청은 강제 리다이렉트 대상에서 제외한다.
|
||||
// (세션 heartbeat 같은 인프라 호출을 302로 튕기면 keepalive JS가 세션만료로 오판하여
|
||||
// 로그인↔홈↔변경페이지 무한 리다이렉트가 발생한다.)
|
||||
if (!isTopLevelHtmlNavigation(request)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String path = request.getServletPath();
|
||||
if (path != null && ALLOWED_PATHS.contains(path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String target = request.getContextPath() + "/password/verify";
|
||||
// 이미 목적지면 재리다이렉트하지 않는다(무한 루프 방지).
|
||||
if (request.getRequestURI().equals(target)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
response.sendRedirect(target);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저 주소창 이동(최상위 HTML 문서 요청)인지 판별한다.
|
||||
* GET + Accept: text/html + 비-AJAX 만 강제 리다이렉트 대상으로 본다.
|
||||
*/
|
||||
private boolean isTopLevelHtmlNavigation(HttpServletRequest request) {
|
||||
if (!"GET".equalsIgnoreCase(request.getMethod())) {
|
||||
return false;
|
||||
}
|
||||
if ("XMLHttpRequest".equalsIgnoreCase(request.getHeader("X-Requested-With"))) {
|
||||
return false;
|
||||
}
|
||||
String fetchMode = request.getHeader("Sec-Fetch-Mode");
|
||||
if (fetchMode != null && !"navigate".equalsIgnoreCase(fetchMode)) {
|
||||
return false;
|
||||
}
|
||||
String accept = request.getHeader("Accept");
|
||||
return accept != null && accept.contains("text/html");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 강제 정책 레벨.
|
||||
*
|
||||
* <p>PTL_PROPERTY (group={@code Portal}, name={@code passwordChangeEnforcement}) 값으로 제어한다.</p>
|
||||
* <ul>
|
||||
* <li>{@link #NONE} — 정책 미적용. 안내/강제 없음.</li>
|
||||
* <li>{@link #PERMISSIVE} — 대상자 로그인 시 1회 안내 팝업만. 강제 없음.</li>
|
||||
* <li>{@link #ENFORCE} — 대상자는 비밀번호 변경 완료 전까지 변경/검증/로그아웃 외 접근 차단.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum PasswordEnforcementPolicy {
|
||||
NONE,
|
||||
PERMISSIVE,
|
||||
ENFORCE;
|
||||
|
||||
/** PTL_PROPERTY 그룹명 */
|
||||
public static final String PROPERTY_GROUP = "Portal";
|
||||
/** PTL_PROPERTY 이름 */
|
||||
public static final String PROPERTY_NAME = "passwordChangeEnforcement";
|
||||
/** 기본값 (배포 직후 동작) */
|
||||
public static final PasswordEnforcementPolicy DEFAULT = ENFORCE;
|
||||
|
||||
/**
|
||||
* 문자열을 정책으로 파싱한다. 대소문자 무시, 미해당/공백이면 {@link #DEFAULT} 반환.
|
||||
*/
|
||||
public static PasswordEnforcementPolicy from(String value) {
|
||||
if (value == null) {
|
||||
return DEFAULT;
|
||||
}
|
||||
try {
|
||||
return PasswordEnforcementPolicy.valueOf(value.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return DEFAULT;
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-6
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.apps.login.constants.LoginConstants;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||
import com.eactive.apim.portal.common.exception.UserNotFoundException;
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
@@ -83,7 +84,7 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
||||
|
||||
|
||||
} catch (UserNotFoundException e) {
|
||||
logger.error("{} login try {}", username, e.getMessage());
|
||||
logger.error("{} login try {}", StringMaskingUtil.maskLoginId(username), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,18 +106,18 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("USER LOGIN FAILURE\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("Username: ").append(username).append("\n");
|
||||
logMessage.append("Session ID: ").append(request.getSession().getId()).append("\n");
|
||||
logMessage.append("Username: ").append(StringMaskingUtil.maskLoginId(username)).append("\n");
|
||||
logMessage.append("Session ID: ").append(StringMaskingUtil.maskToken(request.getSession().getId())).append("\n");
|
||||
logMessage.append("Failed At: ").append(LocalDateTime.now().format(formatter)).append("\n");
|
||||
logMessage.append("Failure Reason: ").append(exception.getLocalizedMessage()).append("\n");
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST INFORMATION\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(HttpRequestUtil.getClientIpAddress(request)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(StringMaskingUtil.maskIpAddress(HttpRequestUtil.getClientIpAddress(request))).append("\n");
|
||||
logMessage.append("Client Host: ").append(HttpRequestUtil.getClientHost(request)).append("\n");
|
||||
logMessage.append("Is Proxied: ").append(HttpRequestUtil.isProxied(request)).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(request.getRemoteAddr()).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(StringMaskingUtil.maskIpAddress(request.getRemoteAddr())).append("\n");
|
||||
logMessage.append("Remote Host (Direct): ").append(request.getRemoteHost()).append("\n");
|
||||
logMessage.append("Request Method: ").append(request.getMethod()).append("\n");
|
||||
logMessage.append("Request URI: ").append(request.getRequestURI()).append("\n");
|
||||
@@ -133,7 +134,7 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
||||
java.util.Enumeration<String> headerValues = request.getHeaders(headerName);
|
||||
while (headerValues.hasMoreElements()) {
|
||||
String headerValue = headerValues.nextElement();
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, headerValue));
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, StringMaskingUtil.maskHeaderValue(headerName, headerValue)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+50
-13
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
@@ -13,6 +14,7 @@ import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
@@ -52,6 +54,7 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final PortalOrgRepository portalOrgRepository;
|
||||
private final UserSessionService userSessionService;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
@Override
|
||||
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||
@@ -85,13 +88,18 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
session.setAttribute("dormantLoginId", username);
|
||||
session.setAttribute("redirectUrl", contextPath + "/dormant_account");
|
||||
} else if (isTemporaryPasswordLogin(user)) {
|
||||
session.setAttribute("success", "임시 비밀번호로 로그인하셨습니다. <br>계정 보안을 위해 비밀번호를 변경해 주세요.");
|
||||
session.setAttribute("passwordExpired", true);
|
||||
session.setAttribute("redirectUrl", contextPath + "/new_password");
|
||||
applyPasswordChangeState(session,
|
||||
"임시 비밀번호로 로그인하셨습니다. <br>계정 보안을 위해 비밀번호를 변경해 주세요.",
|
||||
contextPath + "/password/change");
|
||||
} else if (isPasswordChangeRequired(user)) {
|
||||
session.setAttribute("success", "비밀번호를 변경한 지 90일이 경과하였습니다.<br>계정 보안을 위해 비밀번호를 변경해 주세요.");
|
||||
session.setAttribute("passwordExpired", true);
|
||||
session.setAttribute("redirectUrl", contextPath + "/new_password");
|
||||
applyPasswordChangeState(session,
|
||||
"비밀번호를 변경한 지 " + portalProperties.getPasswordExpirationDays() + "일이 경과하였습니다.<br>계정 보안을 위해 비밀번호를 변경해 주세요.",
|
||||
contextPath + "/password/change");
|
||||
} else if (user.getPasswordChangeDate() == null) {
|
||||
// 비밀번호 변경일 미기록(예: 기존 가입자) → 재설정 대상. 현재 비밀번호 검증 진입 경로로 안내.
|
||||
applyPasswordChangeState(session,
|
||||
"계정 보안을 위해 비밀번호 재설정이 필요합니다.<br>비밀번호를 변경해 주세요.",
|
||||
contextPath + "/password/verify");
|
||||
}
|
||||
|
||||
// 초대 코드 확인 - ROLE_USER만 확인 (세션에 저장하여 메인 페이지에서 팝업으로 표시)
|
||||
@@ -122,8 +130,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);
|
||||
|
||||
// 로그인 성공 시 세션 정보 로깅
|
||||
@@ -143,17 +151,17 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("USER LOGIN SUCCESS\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("Username: ").append(username).append("\n");
|
||||
logMessage.append("Session ID: ").append(session.getId()).append("\n");
|
||||
logMessage.append("Username: ").append(StringMaskingUtil.maskLoginId(username)).append("\n");
|
||||
logMessage.append("Session ID: ").append(StringMaskingUtil.maskToken(session.getId())).append("\n");
|
||||
logMessage.append("Login At: ").append(LocalDateTime.now().format(formatter)).append("\n");
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST INFORMATION\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(HttpRequestUtil.getClientIpAddress(request)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(StringMaskingUtil.maskIpAddress(HttpRequestUtil.getClientIpAddress(request))).append("\n");
|
||||
logMessage.append("Client Host: ").append(HttpRequestUtil.getClientHost(request)).append("\n");
|
||||
logMessage.append("Is Proxied: ").append(HttpRequestUtil.isProxied(request)).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(request.getRemoteAddr()).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(StringMaskingUtil.maskIpAddress(request.getRemoteAddr())).append("\n");
|
||||
logMessage.append("Remote Host (Direct): ").append(request.getRemoteHost()).append("\n");
|
||||
logMessage.append("Request Method: ").append(request.getMethod()).append("\n");
|
||||
logMessage.append("Request URI: ").append(request.getRequestURI()).append("\n");
|
||||
@@ -170,7 +178,7 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
java.util.Enumeration<String> headerValues = request.getHeaders(headerName);
|
||||
while (headerValues.hasMoreElements()) {
|
||||
String headerValue = headerValues.nextElement();
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, headerValue));
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, StringMaskingUtil.maskHeaderValue(headerName, headerValue)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +187,35 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
sessionLogger.info(logMessage.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 대상자에게 정책(NONE/PERMISSIVE/ENFORCE)을 적용한다.
|
||||
* <ul>
|
||||
* <li>NONE — 아무것도 하지 않음.</li>
|
||||
* <li>PERMISSIVE — 안내 알림만(로그인 후 dismissible 팝업). 강제 없음.</li>
|
||||
* <li>ENFORCE — 안내 + 세션 강제 플래그 설정 → 인터셉터가 변경 완료까지 접근 차단.</li>
|
||||
* </ul>
|
||||
*/
|
||||
private void applyPasswordChangeState(HttpSession session, String message, String redirectUrl) {
|
||||
PasswordEnforcementPolicy policy = PasswordEnforcementPolicy.from(
|
||||
portalPropertyService.getOrCreateProperty(
|
||||
PasswordEnforcementPolicy.PROPERTY_GROUP,
|
||||
PasswordEnforcementPolicy.PROPERTY_NAME,
|
||||
PasswordEnforcementPolicy.DEFAULT.name(),
|
||||
"비밀번호 변경 강제 정책 (NONE|PERMISSIVE|ENFORCE)"));
|
||||
|
||||
if (policy == PasswordEnforcementPolicy.NONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.setAttribute("success", message);
|
||||
session.setAttribute("passwordExpired", true);
|
||||
session.setAttribute("redirectUrl", redirectUrl);
|
||||
|
||||
if (policy == PasswordEnforcementPolicy.ENFORCE) {
|
||||
session.setAttribute(PasswordChangeEnforcementInterceptor.ENFORCE_SESSION_ATTR, Boolean.TRUE);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPasswordChangeRequired(PortalUser user) {
|
||||
// 가장 최근 비밀번호 변경 이력 조회
|
||||
Optional<UserPasswordHistory> latestHistory = passwordHistoryRepository
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||
import org.springframework.web.multipart.support.MultipartFilter;
|
||||
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceChainRegistration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
@@ -76,6 +77,17 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
registry.addConverter(enabledStatusConverter());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
// 비밀번호 변경 강제(ENFORCE) 가드. 정적 자원은 제외한다.
|
||||
registry.addInterceptor(new PasswordChangeEnforcementInterceptor())
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns(
|
||||
"/css/**", "/js/**", "/img/**", "/images/**", "/webfonts/**",
|
||||
"/font/**", "/html/**", "/plugins/**", "/favicon.ico",
|
||||
"/api/**");
|
||||
}
|
||||
|
||||
@Bean
|
||||
public EnabledStatusConverter enabledStatusConverter() {
|
||||
return new EnabledStatusConverter();
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -56,11 +57,11 @@ public class PortalLogoutSuccessHandler implements LogoutHandler, LogoutSuccessH
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("USER LOGOUT\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("Session ID: ").append(session.getId()).append("\n");
|
||||
logMessage.append("Session ID: ").append(StringMaskingUtil.maskToken(session.getId())).append("\n");
|
||||
logMessage.append("Logout At: ").append(LocalDateTime.now().format(formatter)).append("\n");
|
||||
|
||||
if (authentication != null) {
|
||||
logMessage.append("Username: ").append(authentication.getName()).append("\n");
|
||||
logMessage.append("Username: ").append(StringMaskingUtil.maskLoginId(authentication.getName())).append("\n");
|
||||
logMessage.append("Authenticated: ").append(authentication.isAuthenticated()).append("\n");
|
||||
}
|
||||
|
||||
@@ -68,10 +69,10 @@ public class PortalLogoutSuccessHandler implements LogoutHandler, LogoutSuccessH
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST INFORMATION\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(HttpRequestUtil.getClientIpAddress(request)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(StringMaskingUtil.maskIpAddress(HttpRequestUtil.getClientIpAddress(request))).append("\n");
|
||||
logMessage.append("Client Host: ").append(HttpRequestUtil.getClientHost(request)).append("\n");
|
||||
logMessage.append("Is Proxied: ").append(HttpRequestUtil.isProxied(request)).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(request.getRemoteAddr()).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(StringMaskingUtil.maskIpAddress(request.getRemoteAddr())).append("\n");
|
||||
logMessage.append("Remote Host (Direct): ").append(request.getRemoteHost()).append("\n");
|
||||
logMessage.append("Request Method: ").append(request.getMethod()).append("\n");
|
||||
logMessage.append("Request URI: ").append(request.getRequestURI()).append("\n");
|
||||
@@ -88,7 +89,7 @@ public class PortalLogoutSuccessHandler implements LogoutHandler, LogoutSuccessH
|
||||
java.util.Enumeration<String> headerValues = request.getHeaders(headerName);
|
||||
while (headerValues.hasMoreElements()) {
|
||||
String headerValue = headerValues.nextElement();
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, headerValue));
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, StringMaskingUtil.maskHeaderValue(headerName, headerValue)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +103,7 @@ public class PortalLogoutSuccessHandler implements LogoutHandler, LogoutSuccessH
|
||||
while (attributeNames.hasMoreElements()) {
|
||||
String attrName = attributeNames.nextElement();
|
||||
Object attrValue = session.getAttribute(attrName);
|
||||
String valueStr = attrValue != null ? attrValue.toString() : "null";
|
||||
String valueStr = StringMaskingUtil.maskAttributeValue(attrName, attrValue != null ? attrValue.toString() : "null");
|
||||
if (valueStr.length() > 100) {
|
||||
valueStr = valueStr.substring(0, 97) + "...";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.slf4j.Logger;
|
||||
@@ -48,7 +49,7 @@ public class SessionLoggingListener implements HttpSessionListener {
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("NEW HTTP SESSION CREATED\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("Session ID: ").append(session.getId()).append("\n");
|
||||
logMessage.append("Session ID: ").append(StringMaskingUtil.maskToken(session.getId())).append("\n");
|
||||
logMessage.append("Created At: ").append(LocalDateTime.now().format(formatter)).append("\n");
|
||||
logMessage.append("Max Inactive Interval: ").append(session.getMaxInactiveInterval()).append(" seconds\n");
|
||||
|
||||
@@ -57,10 +58,10 @@ public class SessionLoggingListener implements HttpSessionListener {
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST INFORMATION\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(HttpRequestUtil.getClientIpAddress(request)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(StringMaskingUtil.maskIpAddress(HttpRequestUtil.getClientIpAddress(request))).append("\n");
|
||||
logMessage.append("Client Host: ").append(HttpRequestUtil.getClientHost(request)).append("\n");
|
||||
logMessage.append("Is Proxied: ").append(HttpRequestUtil.isProxied(request)).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(request.getRemoteAddr()).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(StringMaskingUtil.maskIpAddress(request.getRemoteAddr())).append("\n");
|
||||
logMessage.append("Remote Host (Direct): ").append(request.getRemoteHost()).append("\n");
|
||||
logMessage.append("Remote Port: ").append(request.getRemotePort()).append("\n");
|
||||
logMessage.append("Request Method: ").append(request.getMethod()).append("\n");
|
||||
@@ -84,7 +85,7 @@ public class SessionLoggingListener implements HttpSessionListener {
|
||||
Enumeration<String> headerValues = request.getHeaders(headerName);
|
||||
while (headerValues.hasMoreElements()) {
|
||||
String headerValue = headerValues.nextElement();
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, headerValue));
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, StringMaskingUtil.maskHeaderValue(headerName, headerValue)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -106,7 +107,7 @@ public class SessionLoggingListener implements HttpSessionListener {
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("HTTP SESSION DESTROYED\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("Session ID: ").append(session.getId()).append("\n");
|
||||
logMessage.append("Session ID: ").append(StringMaskingUtil.maskToken(session.getId())).append("\n");
|
||||
logMessage.append("Destroyed At: ").append(LocalDateTime.now().format(formatter)).append("\n");
|
||||
logMessage.append("Max Inactive Interval: ").append(session.getMaxInactiveInterval()).append(" seconds\n");
|
||||
|
||||
@@ -121,7 +122,8 @@ public class SessionLoggingListener implements HttpSessionListener {
|
||||
while (attributeNames.hasMoreElements()) {
|
||||
String attrName = attributeNames.nextElement();
|
||||
Object attrValue = session.getAttribute(attrName);
|
||||
logMessage.append(String.format(" %-30s : %s\n", attrName, attrValue));
|
||||
logMessage.append(String.format(" %-30s : %s\n", attrName,
|
||||
StringMaskingUtil.maskAttributeValue(attrName, attrValue != null ? attrValue.toString() : "null")));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logMessage.append(" Unable to retrieve session attributes\n");
|
||||
|
||||
+10
@@ -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,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);
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
package com.eactive.apim.portal.djb.webhook.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
||||
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookRegistrationDTO;
|
||||
import com.eactive.apim.portal.djb.webhook.service.WebhookEventTypeProvider;
|
||||
import com.eactive.apim.portal.djb.webhook.service.WebhookService;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import javax.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.web.bind.annotation.SessionAttributes;
|
||||
import org.springframework.web.bind.support.SessionStatus;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
/**
|
||||
* Webhook 신청/관리 (기업 사용자, ROLE_APP).
|
||||
*
|
||||
* App API Key 신청의 3-Step + 세션 + 비밀번호 재인증 패턴을 답습하되, 승인 워크플로우 없이 즉시 발급한다.
|
||||
* 참조: {@code apps/app/controller/MyAppController}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Controller
|
||||
@RequestMapping("/webhook")
|
||||
@Secured("ROLE_WEBHOOK")
|
||||
@RequiredArgsConstructor
|
||||
@SessionAttributes({"webhookRegistration", "webhookModification"})
|
||||
public class WebhookController {
|
||||
// Webhook 관리는 법인 관리자 전용(ROLE_WEBHOOK). 조회/신청/수정/삭제/Secret 관리 모두 관리자만 가능.
|
||||
// (개발자 ROLE_CORP_USER 의 앱 권한 확장과 분리하기 위해 ROLE_API_KEY_REQUEST 에서 별도 authority 로 격리)
|
||||
|
||||
private static final int TOTAL_STEPS = 3;
|
||||
|
||||
private final WebhookService webhookService;
|
||||
private final WebhookEventTypeProvider eventTypeProvider;
|
||||
private final ApiServiceService apiServiceService;
|
||||
private final AppServiceFacade appServiceFacade;
|
||||
|
||||
@ModelAttribute("webhookRegistration")
|
||||
public WebhookRegistrationDTO webhookRegistration() {
|
||||
return new WebhookRegistrationDTO();
|
||||
}
|
||||
|
||||
@ModelAttribute("webhookModification")
|
||||
public WebhookRegistrationDTO webhookModification() {
|
||||
return new WebhookRegistrationDTO();
|
||||
}
|
||||
|
||||
// ============================ 조회 ============================
|
||||
|
||||
@Secured("ROLE_WEBHOOK")
|
||||
@GetMapping
|
||||
public ModelAndView index() {
|
||||
String orgId = currentOrgId();
|
||||
Optional<WebhookDTO> webhook = webhookService.getByOrg(orgId);
|
||||
if (webhook.isPresent()) {
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookList");
|
||||
mav.addObject("webhook", webhook.get());
|
||||
return mav;
|
||||
}
|
||||
return new ModelAndView("apps/webhook/webhookEmpty");
|
||||
}
|
||||
|
||||
// ======================= 신규 신청 플로우 =======================
|
||||
|
||||
@GetMapping("/register/step1")
|
||||
public ModelAndView registerStep1(
|
||||
@RequestParam(value = "clear", required = false, defaultValue = "false") boolean clear,
|
||||
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
|
||||
SessionStatus sessionStatus,
|
||||
Model model) {
|
||||
|
||||
if (webhookService.existsByOrg(currentOrgId())) {
|
||||
return new ModelAndView("redirect:/webhook");
|
||||
}
|
||||
if (clear) {
|
||||
sessionStatus.setComplete();
|
||||
registration = new WebhookRegistrationDTO();
|
||||
model.addAttribute("webhookRegistration", registration);
|
||||
}
|
||||
registration.setRequestType("NEW");
|
||||
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep1");
|
||||
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||
addStepModel(mav, 1);
|
||||
return mav;
|
||||
}
|
||||
|
||||
@PostMapping("/register/step1")
|
||||
public ModelAndView processStep1(
|
||||
@Valid @ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
|
||||
BindingResult bindingResult,
|
||||
Model model) {
|
||||
|
||||
validateStep1(registration, bindingResult);
|
||||
if (bindingResult.hasErrors()) {
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep1");
|
||||
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||
addStepModel(mav, 1);
|
||||
return mav;
|
||||
}
|
||||
return new ModelAndView("redirect:/webhook/register/step2");
|
||||
}
|
||||
|
||||
@GetMapping("/register/step2")
|
||||
public ModelAndView registerStep2(
|
||||
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
|
||||
if (!registration.isStep1Complete()) {
|
||||
return new ModelAndView("redirect:/webhook/register/step1");
|
||||
}
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep2");
|
||||
mav.addObject("apiServices", apiServiceService.searchApiGroups(new ApiGroupSearch()));
|
||||
addStepModel(mav, 2);
|
||||
return mav;
|
||||
}
|
||||
|
||||
/** Step2 "이전" — 현재 선택을 세션에 저장하고 Step1 로 복귀 (App 신청 saveStep2 답습). */
|
||||
@PostMapping("/register/step2/save")
|
||||
public ModelAndView saveStep2(
|
||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration) {
|
||||
registration.setSelectedApis(selectedApis != null ? selectedApis : new java.util.ArrayList<>());
|
||||
return new ModelAndView("redirect:/webhook/register/step1");
|
||||
}
|
||||
|
||||
@PostMapping("/register/step2")
|
||||
public ModelAndView processStep2(
|
||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
|
||||
SessionStatus sessionStatus,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
|
||||
registration.setSelectedApis(selectedApis);
|
||||
if (!registration.isStep2Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "알림 대상 API를 1개 이상 선택해주세요.");
|
||||
return new ModelAndView("redirect:/webhook/register/step2");
|
||||
}
|
||||
|
||||
try {
|
||||
// 평문 Secret 은 화면에 노출하지 않는다 — 목록에서 비밀번호 재인증 후 조회.
|
||||
webhookService.create(registration, currentOrgId());
|
||||
sessionStatus.setComplete();
|
||||
redirectAttributes.addFlashAttribute("registrationSuccess", true);
|
||||
return new ModelAndView("redirect:/webhook/register/step3");
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Webhook 신청 실패 orgId={} : {}", currentOrgId(), e.getMessage());
|
||||
redirectAttributes.addFlashAttribute("error", e.getMessage());
|
||||
return new ModelAndView("redirect:/webhook/register/step2");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/register/step3")
|
||||
public ModelAndView registerStep3(Model model) {
|
||||
if (!Boolean.TRUE.equals(model.getAttribute("registrationSuccess"))) {
|
||||
return new ModelAndView("redirect:/webhook");
|
||||
}
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep3");
|
||||
addStepModel(mav, 3);
|
||||
return mav;
|
||||
}
|
||||
|
||||
@GetMapping("/register/cancel")
|
||||
public String cancelRegistration(SessionStatus sessionStatus) {
|
||||
sessionStatus.setComplete();
|
||||
return "redirect:/webhook";
|
||||
}
|
||||
|
||||
// ========================= 수정 플로우 =========================
|
||||
|
||||
@GetMapping("/modify/step1")
|
||||
public ModelAndView modifyStep1(
|
||||
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
|
||||
Model model) {
|
||||
|
||||
Optional<WebhookDTO> current = webhookService.getByOrg(currentOrgId());
|
||||
if (!current.isPresent()) {
|
||||
return new ModelAndView("redirect:/webhook/register/step1?clear=true");
|
||||
}
|
||||
WebhookDTO webhook = current.get();
|
||||
// 세션에 아직 채워지지 않았으면 현재 등록값으로 초기화
|
||||
if (modification.getId() == null || !webhook.getId().equals(modification.getId())) {
|
||||
modification.setId(webhook.getId());
|
||||
modification.setRequestType("MODIFY");
|
||||
modification.setTargetUrl(webhook.getTargetUrl());
|
||||
modification.setEventTypes(webhook.getEventTypes().stream()
|
||||
.map(e -> e.getCode()).collect(java.util.stream.Collectors.toList()));
|
||||
modification.setSelectedApis(new java.util.ArrayList<>(webhook.getApiIds()));
|
||||
model.addAttribute("webhookModification", modification);
|
||||
}
|
||||
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
|
||||
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||
addStepModel(mav, 1);
|
||||
return mav;
|
||||
}
|
||||
|
||||
@PostMapping("/modify/step1")
|
||||
public ModelAndView processModifyStep1(
|
||||
@Valid @ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
|
||||
BindingResult bindingResult,
|
||||
Model model) {
|
||||
|
||||
validateStep1(modification, bindingResult);
|
||||
if (bindingResult.hasErrors()) {
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
|
||||
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||
addStepModel(mav, 1);
|
||||
return mav;
|
||||
}
|
||||
return new ModelAndView("redirect:/webhook/modify/step2");
|
||||
}
|
||||
|
||||
@GetMapping("/modify/step2")
|
||||
public ModelAndView modifyStep2(
|
||||
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification) {
|
||||
|
||||
if (modification.getId() == null || !modification.isStep1Complete()) {
|
||||
return new ModelAndView("redirect:/webhook/modify/step1");
|
||||
}
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep2");
|
||||
mav.addObject("apiServices", apiServiceService.searchApiGroups(new ApiGroupSearch()));
|
||||
addStepModel(mav, 2);
|
||||
return mav;
|
||||
}
|
||||
|
||||
/** 수정 Step2 "이전" — 현재 선택을 세션에 저장하고 Step1 로 복귀. */
|
||||
@PostMapping("/modify/step2/save")
|
||||
public ModelAndView saveModifyStep2(
|
||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification) {
|
||||
modification.setSelectedApis(selectedApis != null ? selectedApis : new java.util.ArrayList<>());
|
||||
return new ModelAndView("redirect:/webhook/modify/step1");
|
||||
}
|
||||
|
||||
@PostMapping("/modify/step2")
|
||||
public ModelAndView processModifyStep2(
|
||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
|
||||
SessionStatus sessionStatus,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
|
||||
modification.setSelectedApis(selectedApis);
|
||||
if (modification.getId() == null || !modification.isStep2Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "알림 대상 API를 1개 이상 선택해주세요.");
|
||||
return new ModelAndView("redirect:/webhook/modify/step2");
|
||||
}
|
||||
try {
|
||||
webhookService.update(modification.getId(), modification, currentOrgId());
|
||||
sessionStatus.setComplete();
|
||||
redirectAttributes.addFlashAttribute("modifySuccess", true);
|
||||
return new ModelAndView("redirect:/webhook/modify/step3");
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Webhook 수정 실패 orgId={} : {}", currentOrgId(), e.getMessage());
|
||||
redirectAttributes.addFlashAttribute("error", e.getMessage());
|
||||
return new ModelAndView("redirect:/webhook/modify/step2");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/modify/step3")
|
||||
public ModelAndView modifyStep3(Model model) {
|
||||
if (!Boolean.TRUE.equals(model.getAttribute("modifySuccess"))) {
|
||||
return new ModelAndView("redirect:/webhook");
|
||||
}
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep3");
|
||||
addStepModel(mav, 3);
|
||||
return mav;
|
||||
}
|
||||
|
||||
@GetMapping("/modify/cancel")
|
||||
public String cancelModification(SessionStatus sessionStatus) {
|
||||
sessionStatus.setComplete();
|
||||
return "redirect:/webhook";
|
||||
}
|
||||
|
||||
// ==================== AJAX (비밀번호 재인증) ====================
|
||||
|
||||
@PostMapping("/verify-secret")
|
||||
@ResponseBody
|
||||
public Map<String, Object> verifySecret(@RequestParam String password) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (!verifyPassword(password)) {
|
||||
result.put("success", false);
|
||||
result.put("message", "비밀번호가 일치하지 않습니다.");
|
||||
return result;
|
||||
}
|
||||
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||
if (!webhook.isPresent()) {
|
||||
result.put("success", false);
|
||||
result.put("message", "등록된 Webhook이 없습니다.");
|
||||
return result;
|
||||
}
|
||||
result.put("success", true);
|
||||
result.put("secret", webhookService.getPlainSecret(webhook.get().getId(), currentOrgId()));
|
||||
return result;
|
||||
}
|
||||
|
||||
@PostMapping("/regenerate-secret")
|
||||
@ResponseBody
|
||||
public Map<String, Object> regenerateSecret(@RequestParam String password) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (!verifyPassword(password)) {
|
||||
result.put("success", false);
|
||||
result.put("message", "비밀번호가 일치하지 않습니다.");
|
||||
return result;
|
||||
}
|
||||
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||
if (!webhook.isPresent()) {
|
||||
result.put("success", false);
|
||||
result.put("message", "등록된 Webhook이 없습니다.");
|
||||
return result;
|
||||
}
|
||||
String secret = webhookService.regenerateSecret(webhook.get().getId(), currentOrgId());
|
||||
result.put("success", true);
|
||||
result.put("secret", secret);
|
||||
return result;
|
||||
}
|
||||
|
||||
@PostMapping("/delete")
|
||||
@ResponseBody
|
||||
public Map<String, Object> delete(@RequestParam String password) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (!verifyPassword(password)) {
|
||||
result.put("success", false);
|
||||
result.put("message", "비밀번호가 일치하지 않습니다.");
|
||||
return result;
|
||||
}
|
||||
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||
if (!webhook.isPresent()) {
|
||||
result.put("success", false);
|
||||
result.put("message", "등록된 Webhook이 없습니다.");
|
||||
return result;
|
||||
}
|
||||
webhookService.delete(webhook.get().getId(), currentOrgId());
|
||||
result.put("success", true);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================ helper ============================
|
||||
|
||||
private void validateStep1(WebhookRegistrationDTO dto, BindingResult bindingResult) {
|
||||
String url = dto.getTargetUrl() == null ? "" : dto.getTargetUrl().trim();
|
||||
if (!bindingResult.hasFieldErrors("targetUrl")
|
||||
&& !url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
bindingResult.rejectValue("targetUrl", "invalid.url",
|
||||
"URL은 http:// 또는 https:// 로 시작해야 합니다.");
|
||||
}
|
||||
if (dto.getEventTypes() == null || dto.getEventTypes().isEmpty()) {
|
||||
bindingResult.rejectValue("eventTypes", "empty.eventTypes",
|
||||
"EventType을 1개 이상 선택해주세요.");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean verifyPassword(String password) {
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
return appServiceFacade.verifyUserPassword(user, password);
|
||||
}
|
||||
|
||||
private String currentOrgId() {
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
return user != null && user.getPortalOrg() != null ? user.getPortalOrg().getId() : null;
|
||||
}
|
||||
|
||||
private void addStepModel(ModelAndView mav, int currentStep) {
|
||||
mav.addObject("currentStep", currentStep);
|
||||
mav.addObject("totalSteps", TOTAL_STEPS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.eactive.apim.portal.djb.webhook.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* 신규 신청 결과. 평문 {@code secret} 은 발급 직후 1회 노출 목적으로만 전달된다.
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public class WebhookCreatedResult {
|
||||
|
||||
private final Long id;
|
||||
private final String secret;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.eactive.apim.portal.djb.webhook.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 등록된 Webhook 상세 표시용. SECRET 은 마스킹 값만 담고 평문은 별도 재인증 조회로만 노출한다.
|
||||
*/
|
||||
@Data
|
||||
public class WebhookDTO implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long id;
|
||||
private String targetUrl;
|
||||
private String secretMasked;
|
||||
private String createdDate;
|
||||
|
||||
/** 구독 API ID 목록. */
|
||||
private List<String> apiIds = new ArrayList<>();
|
||||
|
||||
/** 구독 EventType(코드+한글명) 목록. */
|
||||
private List<WebhookEventTypeDTO> eventTypes = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.eactive.apim.portal.djb.webhook.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* EventType 코드/한글명 쌍. TSEAIRM28(CODEGROUP='EVENT_TYPE') 에서 로드.
|
||||
* {@code selected} 는 신청 화면에서 현재 구독 여부 표시용.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class WebhookEventTypeDTO implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String code;
|
||||
private String name;
|
||||
private boolean selected;
|
||||
|
||||
public WebhookEventTypeDTO(String code, String name) {
|
||||
this.code = code;
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.eactive.apim.portal.djb.webhook.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
import org.hibernate.validator.constraints.Length;
|
||||
import org.hibernate.validator.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* Webhook 신청/수정 스텝 간 세션 보존 데이터 (App 신청 {@code ApiKeyRegistrationDTO} 답습).
|
||||
*
|
||||
* Step1: {@code targetUrl} + {@code eventTypes}. Step2: {@code selectedApis}.
|
||||
*/
|
||||
@Data
|
||||
public class WebhookRegistrationDTO implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 수정 시 대상 신청 ID. 신규 신청이면 null. */
|
||||
private Long id;
|
||||
|
||||
/** "NEW" 또는 "MODIFY" */
|
||||
private String requestType;
|
||||
|
||||
@NotBlank(message = "Webhook 수신 URL을 입력해주세요.")
|
||||
@Length(max = 255, message = "URL은 255자를 초과할 수 없습니다.")
|
||||
private String targetUrl;
|
||||
|
||||
/** Step1: 구독 EventType 코드 목록 (TSEAIRM28 EVENT_TYPE). */
|
||||
private List<String> eventTypes = new ArrayList<>();
|
||||
|
||||
/** Step2: 알림 대상 API ID 목록. */
|
||||
private List<String> selectedApis = new ArrayList<>();
|
||||
|
||||
public boolean isStep1Complete() {
|
||||
return targetUrl != null && !targetUrl.trim().isEmpty()
|
||||
&& eventTypes != null && !eventTypes.isEmpty();
|
||||
}
|
||||
|
||||
public boolean isStep2Complete() {
|
||||
return selectedApis != null && !selectedApis.isEmpty();
|
||||
}
|
||||
|
||||
public boolean isComplete() {
|
||||
return isStep1Complete() && isStep2Complete();
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.eactive.apim.portal.djb.webhook.exception;
|
||||
|
||||
/**
|
||||
* Org 당 Webhook 1건 정책 위반(이미 등록됨).
|
||||
*/
|
||||
public class WebhookAlreadyExistsException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public WebhookAlreadyExistsException(String orgId) {
|
||||
super("이미 등록된 Webhook이 있습니다. orgId=" + orgId);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.eactive.apim.portal.djb.webhook.exception;
|
||||
|
||||
/**
|
||||
* 대상 Webhook 신청을 찾을 수 없음.
|
||||
*/
|
||||
public class WebhookNotFoundException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public WebhookNotFoundException(Long id) {
|
||||
super("Webhook 신청을 찾을 수 없습니다. id=" + id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.eactive.apim.portal.djb.webhook.mapper;
|
||||
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
|
||||
/**
|
||||
* WebhookRequest → WebhookDTO 기본 필드 매핑.
|
||||
* secretMasked/apiIds/eventTypes 는 연관 테이블 조립이 필요하므로 서비스에서 채운다.
|
||||
*/
|
||||
@Mapper(componentModel = "spring")
|
||||
public interface WebhookMapper {
|
||||
|
||||
@Mapping(target = "secretMasked", ignore = true)
|
||||
@Mapping(target = "apiIds", ignore = true)
|
||||
@Mapping(target = "eventTypes", ignore = true)
|
||||
WebhookDTO toDto(WebhookRequest entity);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository;
|
||||
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestApi;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestApiId;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* PTL_WEBHOOK_REQ_API — 신청별 구독 API 목록.
|
||||
*/
|
||||
@EMSDataSource
|
||||
public interface WebhookRequestApiRepository extends JpaRepository<WebhookRequestApi, WebhookRequestApiId> {
|
||||
|
||||
List<WebhookRequestApi> findByWebhookReqId(Long webhookReqId);
|
||||
|
||||
@Transactional
|
||||
void deleteByWebhookReqId(Long webhookReqId);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository;
|
||||
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestEvent;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestEventId;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* PTL_WEBHOOK_REQ_EVENT — 신청별 구독 EventType 목록.
|
||||
*/
|
||||
@EMSDataSource
|
||||
public interface WebhookRequestEventRepository extends JpaRepository<WebhookRequestEvent, WebhookRequestEventId> {
|
||||
|
||||
List<WebhookRequestEvent> findByWebhookReqId(Long webhookReqId);
|
||||
|
||||
@Transactional
|
||||
void deleteByWebhookReqId(Long webhookReqId);
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository;
|
||||
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/**
|
||||
* PTL_WEBHOOK_REQ 조회/저장. Org 당 1건 정책이라 orgId 단건 조회를 제공한다.
|
||||
*/
|
||||
@EMSDataSource
|
||||
public interface WebhookRequestRepository extends JpaRepository<WebhookRequest, Long> {
|
||||
|
||||
Optional<WebhookRequest> findByOrgId(String orgId);
|
||||
|
||||
boolean existsByOrgId(String orgId);
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.PrePersist;
|
||||
import javax.persistence.SequenceGenerator;
|
||||
import javax.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Webhook 신청 마스터 (EMSAPP.PTL_WEBHOOK_REQ).
|
||||
*
|
||||
* admin(eapim-admin) 발송엔진이 이 행을 읽어 TARGET_URL 로 HMAC-SHA256 서명 발송한다.
|
||||
* SECRET 은 서명 키로 그대로 사용되므로 암호화하지 않고 평문 저장한다.
|
||||
* CREATED_DATE 는 VARCHAR2(14) yyyyMMddHHmmss 문자열이라 AbstractAuditingEntity 를 쓰지 않고
|
||||
* {@link #onCreate()} 에서 직접 세팅한다.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "PTL_WEBHOOK_REQ")
|
||||
public class WebhookRequest implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "webhookReqSeq")
|
||||
@SequenceGenerator(name = "webhookReqSeq", sequenceName = "SEQ_PTL_WEBHOOK_REQ_ID", allocationSize = 1)
|
||||
@Column(name = "ID")
|
||||
private Long id;
|
||||
|
||||
@Column(name = "ORG_ID", length = 36)
|
||||
private String orgId;
|
||||
|
||||
@Column(name = "TARGET_URL", length = 255)
|
||||
private String targetUrl;
|
||||
|
||||
@Column(name = "SECRET", length = 500)
|
||||
private String secret;
|
||||
|
||||
@Column(name = "CREATED_BY", length = 200)
|
||||
private String createdBy;
|
||||
|
||||
@Column(name = "CREATED_DATE", length = 14)
|
||||
private String createdDate;
|
||||
|
||||
@PrePersist
|
||||
public void onCreate() {
|
||||
if (createdBy == null) {
|
||||
createdBy = SecurityUtil.getCurrentLoginId();
|
||||
}
|
||||
if (createdDate == null) {
|
||||
createdDate = LocalDateTime.now().format(TS);
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.IdClass;
|
||||
import javax.persistence.PrePersist;
|
||||
import javax.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Webhook 신청이 알림을 받을 API 목록 (EMSAPP.PTL_WEBHOOK_REQ_API).
|
||||
* 복합키(WEBHOOK_REQ_ID + API_ID).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "PTL_WEBHOOK_REQ_API")
|
||||
@IdClass(WebhookRequestApiId.class)
|
||||
public class WebhookRequestApi implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
|
||||
@Id
|
||||
@Column(name = "WEBHOOK_REQ_ID")
|
||||
private Long webhookReqId;
|
||||
|
||||
@Id
|
||||
@Column(name = "API_ID", length = 30)
|
||||
private String apiId;
|
||||
|
||||
@Column(name = "CREATED_BY", length = 200)
|
||||
private String createdBy;
|
||||
|
||||
@Column(name = "CREATED_DATE", length = 14)
|
||||
private String createdDate;
|
||||
|
||||
public WebhookRequestApi() {
|
||||
}
|
||||
|
||||
public WebhookRequestApi(Long webhookReqId, String apiId) {
|
||||
this.webhookReqId = webhookReqId;
|
||||
this.apiId = apiId;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
public void onCreate() {
|
||||
if (createdBy == null) {
|
||||
createdBy = SecurityUtil.getCurrentLoginId();
|
||||
}
|
||||
if (createdDate == null) {
|
||||
createdDate = LocalDateTime.now().format(TS);
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* {@link WebhookRequestApi} 복합키 (WEBHOOK_REQ_ID + API_ID).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode
|
||||
public class WebhookRequestApiId implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long webhookReqId;
|
||||
private String apiId;
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.IdClass;
|
||||
import javax.persistence.PrePersist;
|
||||
import javax.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Webhook 신청이 구독하는 EventType 목록 (EMSAPP.PTL_WEBHOOK_REQ_EVENT).
|
||||
* EVENT_TYPE 코드값은 EMSAPP.TSEAIRM28(CODEGROUP='EVENT_TYPE') 과 동일 집합.
|
||||
* 복합키(WEBHOOK_REQ_ID + EVENT_TYPE).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "PTL_WEBHOOK_REQ_EVENT")
|
||||
@IdClass(WebhookRequestEventId.class)
|
||||
public class WebhookRequestEvent implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
|
||||
@Id
|
||||
@Column(name = "WEBHOOK_REQ_ID")
|
||||
private Long webhookReqId;
|
||||
|
||||
@Id
|
||||
@Column(name = "EVENT_TYPE", length = 36)
|
||||
private String eventType;
|
||||
|
||||
@Column(name = "CREATED_BY", length = 200)
|
||||
private String createdBy;
|
||||
|
||||
@Column(name = "CREATED_DATE", length = 14)
|
||||
private String createdDate;
|
||||
|
||||
public WebhookRequestEvent() {
|
||||
}
|
||||
|
||||
public WebhookRequestEvent(Long webhookReqId, String eventType) {
|
||||
this.webhookReqId = webhookReqId;
|
||||
this.eventType = eventType;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
public void onCreate() {
|
||||
if (createdBy == null) {
|
||||
createdBy = SecurityUtil.getCurrentLoginId();
|
||||
}
|
||||
if (createdDate == null) {
|
||||
createdDate = LocalDateTime.now().format(TS);
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* {@link WebhookRequestEvent} 복합키 (WEBHOOK_REQ_ID + EVENT_TYPE).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode
|
||||
public class WebhookRequestEventId implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long webhookReqId;
|
||||
private String eventType;
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.eactive.apim.portal.djb.webhook.service;
|
||||
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.persistence.EntityManager;
|
||||
import javax.persistence.PersistenceContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* EventType 코드/한글명 제공자. EMSAPP.TSEAIRM28(CODEGROUP='EVENT_TYPE', USEYN='Y') 를 조회한다.
|
||||
* admin 발송엔진과 동일 공통코드 테이블을 단일 소스로 사용하므로 코드 추가/변경 시 DB 만 수정하면 된다.
|
||||
*
|
||||
* TSEAIRM28 은 elink-portal-common 의 {@code MonitoringCode} 엔티티가 이미 매핑하고 있어(같은 테이블 중복 @Entity 금지),
|
||||
* 여기서는 별도 엔티티 없이 EMS EntityManager 네이티브 쿼리로 필요한 두 컬럼만 조회한다.
|
||||
*/
|
||||
@Component
|
||||
public class WebhookEventTypeProvider {
|
||||
|
||||
private static final String EVENT_TYPE_SQL =
|
||||
"SELECT CODE, CODENAME FROM TSEAIRM28 "
|
||||
+ "WHERE CODEGROUP = 'EVENT_TYPE' AND USEYN = 'Y' "
|
||||
+ "ORDER BY SEQ, CODE";
|
||||
|
||||
/** EMS 데이터소스가 @Primary 이므로 기본 EntityManager 는 EMS 를 가리킨다. */
|
||||
@PersistenceContext
|
||||
private EntityManager entityManager;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<WebhookEventTypeDTO> getAll() {
|
||||
List<WebhookEventTypeDTO> list = new ArrayList<>();
|
||||
for (Object[] row : rows()) {
|
||||
list.add(new WebhookEventTypeDTO(asString(row[0]), asString(row[1])));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Map<String, String> asMap() {
|
||||
Map<String, String> map = new LinkedHashMap<>();
|
||||
for (Object[] row : rows()) {
|
||||
map.put(asString(row[0]), asString(row[1]));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isValid(String code) {
|
||||
if (code == null) {
|
||||
return false;
|
||||
}
|
||||
for (Object[] row : rows()) {
|
||||
if (code.equals(asString(row[0]))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public String getName(String code) {
|
||||
return asMap().getOrDefault(code, code);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Object[]> rows() {
|
||||
return entityManager.createNativeQuery(EVENT_TYPE_SQL).getResultList();
|
||||
}
|
||||
|
||||
private String asString(Object value) {
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.eactive.apim.portal.djb.webhook.service;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Webhook HMAC Secret 생성기.
|
||||
*
|
||||
* admin 발송엔진이 이 값을 그대로 HMAC-SHA256 키로 사용하므로(암복호화 없음),
|
||||
* 128자 영숫자 랜덤 문자열을 평문으로 발급한다(admin 기존 데이터와 동일 형태).
|
||||
*/
|
||||
@Component
|
||||
public class WebhookSecretGenerator {
|
||||
|
||||
private static final char[] ALPHANUM =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray();
|
||||
private static final int LENGTH = 128;
|
||||
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
public String generate() {
|
||||
StringBuilder sb = new StringBuilder(LENGTH);
|
||||
for (int i = 0; i < LENGTH; i++) {
|
||||
sb.append(ALPHANUM[random.nextInt(ALPHANUM.length)]);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.eactive.apim.portal.djb.webhook.service;
|
||||
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookCreatedResult;
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO;
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookRegistrationDTO;
|
||||
import com.eactive.apim.portal.djb.webhook.exception.WebhookAlreadyExistsException;
|
||||
import com.eactive.apim.portal.djb.webhook.exception.WebhookNotFoundException;
|
||||
import com.eactive.apim.portal.djb.webhook.mapper.WebhookMapper;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestApiRepository;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventRepository;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestRepository;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestApi;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestEvent;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Webhook 신청/조회/수정/삭제 및 Secret 발급 오케스트레이션.
|
||||
*
|
||||
* 단일 EMS 데이터소스만 사용하므로 기본 {@code @Transactional} 로 충분하다(JTA 분산 트랜잭션 불필요).
|
||||
* 모든 수정/삭제/Secret 조회는 소유 Org 검증({@link #loadOwned})을 거친다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
public class WebhookService {
|
||||
|
||||
private static final String SECRET_MASK = "••••••••••••";
|
||||
|
||||
private final WebhookRequestRepository requestRepository;
|
||||
private final WebhookRequestApiRepository apiRepository;
|
||||
private final WebhookRequestEventRepository eventRepository;
|
||||
private final WebhookSecretGenerator secretGenerator;
|
||||
private final WebhookEventTypeProvider eventTypeProvider;
|
||||
private final WebhookMapper webhookMapper;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public boolean existsByOrg(String orgId) {
|
||||
return requestRepository.existsByOrgId(orgId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<WebhookDTO> getByOrg(String orgId) {
|
||||
return requestRepository.findByOrgId(orgId).map(this::toDetailDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 신규 신청. Org 당 1건 정책 위반 시 예외. 평문 Secret 을 1회 반환한다.
|
||||
*/
|
||||
public WebhookCreatedResult create(WebhookRegistrationDTO dto, String orgId) {
|
||||
if (requestRepository.existsByOrgId(orgId)) {
|
||||
throw new WebhookAlreadyExistsException(orgId);
|
||||
}
|
||||
validate(dto);
|
||||
|
||||
String secret = secretGenerator.generate();
|
||||
WebhookRequest request = new WebhookRequest();
|
||||
request.setOrgId(orgId);
|
||||
request.setTargetUrl(dto.getTargetUrl().trim());
|
||||
request.setSecret(secret);
|
||||
WebhookRequest saved = requestRepository.save(request);
|
||||
|
||||
persistChildren(saved.getId(), dto);
|
||||
log.info("Webhook 신규 등록 orgId={} id={}", orgId, saved.getId());
|
||||
return new WebhookCreatedResult(saved.getId(), secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL/API/EventType 수정. Secret 은 보존한다. 연관 테이블은 delete-all 후 재삽입.
|
||||
*/
|
||||
public WebhookDTO update(Long id, WebhookRegistrationDTO dto, String orgId) {
|
||||
WebhookRequest request = loadOwned(id, orgId);
|
||||
validate(dto);
|
||||
|
||||
request.setTargetUrl(dto.getTargetUrl().trim());
|
||||
requestRepository.save(request);
|
||||
|
||||
apiRepository.deleteByWebhookReqId(id);
|
||||
eventRepository.deleteByWebhookReqId(id);
|
||||
apiRepository.flush();
|
||||
eventRepository.flush();
|
||||
persistChildren(id, dto);
|
||||
|
||||
log.info("Webhook 수정 orgId={} id={}", orgId, id);
|
||||
return toDetailDto(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret 재발급(교체). 새 평문 Secret 반환.
|
||||
*/
|
||||
public String regenerateSecret(Long id, String orgId) {
|
||||
WebhookRequest request = loadOwned(id, orgId);
|
||||
String secret = secretGenerator.generate();
|
||||
request.setSecret(secret);
|
||||
requestRepository.save(request);
|
||||
log.info("Webhook Secret 재발급 orgId={} id={}", orgId, id);
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 3개 테이블 HardDelete.
|
||||
*/
|
||||
public void delete(Long id, String orgId) {
|
||||
WebhookRequest request = loadOwned(id, orgId);
|
||||
apiRepository.deleteByWebhookReqId(id);
|
||||
eventRepository.deleteByWebhookReqId(id);
|
||||
requestRepository.delete(request);
|
||||
log.info("Webhook 삭제 orgId={} id={}", orgId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 평문 Secret 조회. 컨트롤러에서 비밀번호 재인증 후에만 호출한다.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public String getPlainSecret(Long id, String orgId) {
|
||||
return loadOwned(id, orgId).getSecret();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
private WebhookRequest loadOwned(Long id, String orgId) {
|
||||
WebhookRequest request = requestRepository.findById(id)
|
||||
.orElseThrow(() -> new WebhookNotFoundException(id));
|
||||
if (!Objects.equals(request.getOrgId(), orgId)) {
|
||||
throw new AccessDeniedException("해당 Webhook에 대한 권한이 없습니다.");
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
private void persistChildren(Long reqId, WebhookRegistrationDTO dto) {
|
||||
for (String apiId : dedup(dto.getSelectedApis())) {
|
||||
apiRepository.save(new WebhookRequestApi(reqId, apiId));
|
||||
}
|
||||
for (String eventType : dedup(dto.getEventTypes())) {
|
||||
if (!eventTypeProvider.isValid(eventType)) {
|
||||
throw new IllegalArgumentException("유효하지 않은 EventType 코드입니다: " + eventType);
|
||||
}
|
||||
eventRepository.save(new WebhookRequestEvent(reqId, eventType));
|
||||
}
|
||||
}
|
||||
|
||||
private void validate(WebhookRegistrationDTO dto) {
|
||||
String url = dto.getTargetUrl() == null ? "" : dto.getTargetUrl().trim();
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
throw new IllegalArgumentException("URL은 http:// 또는 https:// 로 시작해야 합니다.");
|
||||
}
|
||||
if (dto.getEventTypes() == null || dto.getEventTypes().isEmpty()) {
|
||||
throw new IllegalArgumentException("EventType을 1개 이상 선택해주세요.");
|
||||
}
|
||||
if (dto.getSelectedApis() == null || dto.getSelectedApis().isEmpty()) {
|
||||
throw new IllegalArgumentException("알림 대상 API를 1개 이상 선택해주세요.");
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> dedup(List<String> values) {
|
||||
if (values == null) {
|
||||
return java.util.Collections.emptyList();
|
||||
}
|
||||
return new java.util.ArrayList<>(new LinkedHashSet<>(values));
|
||||
}
|
||||
|
||||
private WebhookDTO toDetailDto(WebhookRequest request) {
|
||||
WebhookDTO dto = webhookMapper.toDto(request);
|
||||
dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK);
|
||||
|
||||
dto.setApiIds(apiRepository.findByWebhookReqId(request.getId()).stream()
|
||||
.map(WebhookRequestApi::getApiId)
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
Map<String, String> names = eventTypeProvider.asMap();
|
||||
dto.setEventTypes(eventRepository.findByWebhookReqId(request.getId()).stream()
|
||||
.map(e -> new WebhookEventTypeDTO(e.getEventType(),
|
||||
names.getOrDefault(e.getEventType(), e.getEventType())))
|
||||
.collect(Collectors.toList()));
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
@@ -30,6 +29,8 @@ spring:
|
||||
default-page-size: '10'
|
||||
jpa:
|
||||
open-in-view: false
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
|
||||
web:
|
||||
resources:
|
||||
@@ -203,6 +204,10 @@ portal:
|
||||
method: GET
|
||||
view-name: apps/service/oauth2-guide
|
||||
|
||||
- path-pattern: /service/webhook-dev-guide
|
||||
method: GET
|
||||
view-name: apps/service/webhook-dev-guide
|
||||
|
||||
- path-pattern: /dashboard
|
||||
method: GET
|
||||
view-name: apps/mypage/dashboard
|
||||
@@ -219,12 +224,15 @@ portal:
|
||||
- ROLE_INQUIRY
|
||||
- ROLE_ACCOUNT
|
||||
ROLE_CORP_USER:
|
||||
- ROLE_API_KEY_REQUEST
|
||||
- ROLE_API_KEY_REQUEST_VIEW
|
||||
- ROLE_INQUIRY
|
||||
- ROLE_APP
|
||||
- ROLE_ACCOUNT
|
||||
ROLE_CORP_MANAGER:
|
||||
- ROLE_API_KEY_REQUEST
|
||||
- ROLE_API_KEY_REQUEST_VIEW
|
||||
- ROLE_WEBHOOK
|
||||
- ROLE_INQUIRY
|
||||
- ROLE_APP
|
||||
- ROLE_ACCOUNT
|
||||
@@ -295,6 +303,9 @@ page:
|
||||
oauth2_guide:
|
||||
name: "OAuth2 개발가이드"
|
||||
path: "/service/oauth2-guide"
|
||||
webhook_dev_guide:
|
||||
name: "웹훅 개발가이드"
|
||||
path: "/service/webhook-dev-guide"
|
||||
apis:
|
||||
name: "API"
|
||||
path: "#"
|
||||
@@ -347,13 +358,13 @@ page:
|
||||
name: "이메일 인증"
|
||||
path: "/mypage/verification-email"
|
||||
user_list:
|
||||
name: "이용자 관리"
|
||||
name: "개발자 관리"
|
||||
path: "/users"
|
||||
user_detail:
|
||||
name: "이용자 정보"
|
||||
name: "개발자 정보"
|
||||
path: "/users/detail"
|
||||
apikey:
|
||||
name: "인증키 관리"
|
||||
name: "앱 관리"
|
||||
path: "/myapikey"
|
||||
app_request_detail:
|
||||
name: "인증키 신청 상세"
|
||||
@@ -361,12 +372,12 @@ page:
|
||||
credential_detail:
|
||||
name: "인증키 정보"
|
||||
path: "/myapikey/credential_detail"
|
||||
change_password:
|
||||
password_verify:
|
||||
name: "비밀번호 변경"
|
||||
path: "/change_password"
|
||||
verify_current_password:
|
||||
path: "/password/verify"
|
||||
password_change:
|
||||
name: "비밀번호 변경"
|
||||
path: "/verify_current_password"
|
||||
path: "/password/change"
|
||||
myapikey_register_step1:
|
||||
name: "앱 생성 (기본 정보)"
|
||||
path: "/myapikey/register/step1"
|
||||
@@ -388,6 +399,27 @@ page:
|
||||
api_statistics:
|
||||
name: "이용 통계"
|
||||
path: "/statistics/api"
|
||||
webhook:
|
||||
name: "Webhook 관리"
|
||||
path: "/webhook"
|
||||
webhook_register_step1:
|
||||
name: "Webhook 신청 (기본 정보)"
|
||||
path: "/webhook/register/step1"
|
||||
webhook_register_step2:
|
||||
name: "Webhook 신청 (API 선택)"
|
||||
path: "/webhook/register/step2"
|
||||
webhook_register_step3:
|
||||
name: "Webhook 신청 완료"
|
||||
path: "/webhook/register/step3"
|
||||
webhook_modify_step1:
|
||||
name: "Webhook 수정 (기본 정보)"
|
||||
path: "/webhook/modify/step1"
|
||||
webhook_modify_step2:
|
||||
name: "Webhook 수정 (API 선택)"
|
||||
path: "/webhook/modify/step2"
|
||||
webhook_modify_step3:
|
||||
name: "Webhook 수정 완료"
|
||||
path: "/webhook/modify/step3"
|
||||
|
||||
# 에디터 이미지 설정 (약관 이미지 표시용)
|
||||
editor:
|
||||
|
||||
@@ -56,6 +56,37 @@
|
||||
</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>
|
||||
|
||||
<!-- ERROR 레벨만 별도 수집(스택 트레이스 포함). 장애 원인 추적용. -->
|
||||
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/error.log</file>
|
||||
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||
<level>ERROR</level>
|
||||
<onMatch>ACCEPT</onMatch>
|
||||
<onMismatch>DENY</onMismatch>
|
||||
</filter>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/backup/error.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||
<maxFileSize>200MB</maxFileSize>
|
||||
<maxHistory>30</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %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,16 +99,22 @@
|
||||
<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"/>
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="ERROR_FILE"/>
|
||||
</root>
|
||||
|
||||
<springProfile name="dev">
|
||||
<root level="DEBUG">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="ERROR_FILE"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
</configuration>
|
||||
@@ -39,7 +39,7 @@ deptUserManageRegister.id=Department User ID
|
||||
deptUserManageRegister.name=Department User Name
|
||||
portalUser.confirm.password=Please enter your existing password
|
||||
portalUser.Register.userName=Name
|
||||
portalUser.Register.pass=Password (Combination of uppercase letters, lowercase letters, numbers, special characters, 8-20 characters)
|
||||
portalUser.Register.pass=Password (Combination of uppercase letters, lowercase letters, numbers, special characters, 8-50 characters)
|
||||
portalUser.Register.passConfirm=Confirm Password
|
||||
portalUser.Register.email=Email ID
|
||||
portalUser.Register.domain=Domain
|
||||
@@ -82,6 +82,6 @@ status=Status
|
||||
table.reger=Registrar
|
||||
zip=Zip Code
|
||||
ROLE_CORP_MANAGER=Manager
|
||||
ROLE_CORP_USER=User
|
||||
ROLE_CORP_USER=Developer
|
||||
ROLE_USER=Individual
|
||||
login.passLengthShort=Password must be over 8 characters.
|
||||
|
||||
@@ -39,7 +39,7 @@ deptUserManageRegister.name=\uBD80\uC11C \uC0AC\uC6A9\uC790 \uC774\uB984
|
||||
entrprsUserManageList.regName=\uBC95\uC778 \uC0AC\uC6A9\uC790 \uB4F1\uB85D \uC774\uB984
|
||||
portalUser.confirm.password=\uAE30\uC874 \uBE44\uBC00\uBC88\uD638\uB97C \uC785\uB825\uD574\uC8FC\uC138\uC694
|
||||
portalUser.Register.userName=\uC774\uB984
|
||||
portalUser.Register.pass=\uC601\uBB38 \uB300\uBB38\uC790,\uC601\uBB38 \uC18C\uBB38\uC790,\uC22B\uC790,\uD2B9\uC218\uBB38\uC790 \uC870\uD569 8-20\uC790
|
||||
portalUser.Register.pass=\uC601\uBB38 \uB300\uBB38\uC790,\uC601\uBB38 \uC18C\uBB38\uC790,\uC22B\uC790,\uD2B9\uC218\uBB38\uC790 \uC870\uD569 8-50\uC790
|
||||
portalUser.Register.passConfirm=\uBE44\uBC00\uBC88\uD638 \uD655\uC778
|
||||
portalUser.Register.email=\uC774\uBA54\uC77C \uC544\uC774\uB514
|
||||
portalUser.Register.domain=\uB3C4\uBA54\uC778
|
||||
@@ -82,6 +82,6 @@ status=\uC0C1\uD0DC
|
||||
table.reger=\uB4F1\uB85D\uC790
|
||||
zip=\uC6B0\uD3B8\uBC88\uD638
|
||||
ROLE_CORP_MANAGER=\uAD00\uB9AC\uC790
|
||||
ROLE_CORP_USER=\uC774\uC6A9\uC790
|
||||
ROLE_CORP_USER=\uAC1C\uBC1C\uC790
|
||||
ROLE_USER=\uAC1C\uC778\uC0AC\uC6A9\uC790
|
||||
ConcurrentSessionControlAuthenticationStrategy.exceededAllowed=\uCD5C\uB300 \uB85C\uADF8\uC778 \uD5C8\uC6A9 \uC5F0\uACB0\uC744 \uCD08\uACFC\uD558\uC600\uC2B5\uB2C8\uB2E4.
|
||||
|
||||
+1933
-328
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+28404
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* API 선택 공용 모듈 (fragment/api_selector.html 전용, figma s2 디자인)
|
||||
*
|
||||
* 사용처: 앱(API Key) 신청/수정 step2, Webhook 신청/수정 step2
|
||||
*
|
||||
* 계약:
|
||||
* - 폼: #apiSelectorForm (data-save-action = "이전" 저장 POST 경로)
|
||||
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
|
||||
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
|
||||
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
|
||||
*
|
||||
* design(figma s2) 인라인 스크립트 대비 패치 3건:
|
||||
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
|
||||
* 2) 모달 리스트를 DOM 체크박스가 아닌 selectedApis Set 기준으로 생성 — 미렌더/타 카테고리 누락 방지
|
||||
* 3) 제출/이전 시 DOM에 없는 선택분을 hidden input으로 주입 — 카테고리 필터 상태 전송 유실 방지
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('apiSelectorForm');
|
||||
if (!form) {
|
||||
return; // 모듈 미사용 페이지
|
||||
}
|
||||
|
||||
// DOM Elements
|
||||
const searchInput = document.getElementById('apiSearch');
|
||||
const menuTitles = document.querySelectorAll('.s2-category-tab');
|
||||
const apiCardGrid = document.getElementById('apiCardGrid');
|
||||
const loadingState = document.getElementById('loadingState');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
|
||||
let currentFilter = ''; // Empty string means "all"
|
||||
let currentServiceName = '전체';
|
||||
let allApis = [];
|
||||
let selectedApis = new Set();
|
||||
|
||||
// Restore selected APIs from session (fragment 인라인 주입)
|
||||
const sessionSelectedApis = window.API_SELECTOR_SELECTED;
|
||||
if (sessionSelectedApis && Array.isArray(sessionSelectedApis)) {
|
||||
sessionSelectedApis.forEach(function(apiId) {
|
||||
selectedApis.add(apiId);
|
||||
});
|
||||
}
|
||||
|
||||
// Load APIs via AJAX
|
||||
function loadApis(groupId) {
|
||||
loadingState.style.display = 'block';
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
|
||||
|
||||
const baseUrl = window.API_SELECTOR_LIST_URL || '/apis/for_request';
|
||||
let url = baseUrl;
|
||||
if (groupId) {
|
||||
url += '?groupIds=' + encodeURIComponent(groupId);
|
||||
}
|
||||
|
||||
fetch(url).then(response => response.json()).then(apis => {
|
||||
allApis = apis;
|
||||
loadingState.style.display = 'none';
|
||||
|
||||
if (apis.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
document.getElementById('apiResultCount').textContent = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('apiResultCount').textContent = apis.length;
|
||||
renderApiCards(apis);
|
||||
updateSelectAllUI();
|
||||
}).catch(error => {
|
||||
console.error('Failed to load APIs:', error);
|
||||
loadingState.style.display = 'none';
|
||||
emptyState.querySelector('h3').textContent = 'API 로드 실패';
|
||||
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
|
||||
emptyState.style.display = 'block';
|
||||
document.getElementById('apiResultCount').textContent = '0';
|
||||
});
|
||||
}
|
||||
|
||||
// Render API cards
|
||||
function renderApiCards(apis) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
apis.forEach(api => {
|
||||
fragment.appendChild(createApiCard(api));
|
||||
});
|
||||
|
||||
apiCardGrid.appendChild(fragment);
|
||||
attachCardEventListeners();
|
||||
}
|
||||
|
||||
// Create API card element (figma s2 card)
|
||||
function createApiCard(api) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 's2-api-card';
|
||||
card.setAttribute('data-group', api.apiGroupId || '');
|
||||
card.setAttribute('data-name', (api.apiName || '').toLowerCase());
|
||||
card.setAttribute('data-desc', (api.apiSimpleDescription || '').toLowerCase());
|
||||
card.setAttribute('data-api-id', api.apiId);
|
||||
|
||||
const isSelected = selectedApis.has(api.apiId);
|
||||
if (isSelected) {
|
||||
card.classList.add('selected');
|
||||
}
|
||||
|
||||
const mainIconHtml = api.mainIcon
|
||||
? `<img src="${api.mainIcon}" alt="${api.apiName}" onerror="this.style.display='none'; this.nextElementSibling.style.display='block'"><i class="fas fa-cube" style="display:none"></i>`
|
||||
: `<i class="fas fa-cube"></i>`;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="s2-api-card-badge">
|
||||
<span>${api.apiGroupName || api.service || '카테고리'}</span>
|
||||
</div>
|
||||
<!-- Checkbox container with visible custom design -->
|
||||
<label class="s2-checkbox-wrapper">
|
||||
<input type="checkbox"
|
||||
name="selectedApis"
|
||||
value="${api.apiId}"
|
||||
id="api-${api.apiId}"
|
||||
class="s2-api-checkbox visually-hidden"
|
||||
${isSelected ? 'checked' : ''}>
|
||||
<span class="s2-checkbox-custom"></span>
|
||||
</label>
|
||||
|
||||
<h3 class="s2-api-card-title">${api.apiName || 'API 이름'}</h3>
|
||||
<p class="s2-api-card-desc">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
|
||||
|
||||
<div class="s2-api-card-image">
|
||||
${mainIconHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// Attach event listeners to cards
|
||||
function attachCardEventListeners() {
|
||||
const apiCards = document.querySelectorAll('.s2-api-card');
|
||||
|
||||
apiCards.forEach(card => {
|
||||
card.addEventListener('click', function(e) {
|
||||
const checkbox = card.querySelector('.s2-api-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
updateCardSelection(checkbox);
|
||||
updateSelectedCount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Prevent double toggle when clicking the checkbox wrapper
|
||||
const checkboxWrappers = document.querySelectorAll('.s2-checkbox-wrapper');
|
||||
checkboxWrappers.forEach(wrapper => {
|
||||
wrapper.addEventListener('click', function(e) {
|
||||
e.stopPropagation(); // Stop click from bubbling to card!
|
||||
});
|
||||
|
||||
const checkbox = wrapper.querySelector('.s2-api-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.addEventListener('change', function() {
|
||||
updateCardSelection(this);
|
||||
updateSelectedCount();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update card visual state
|
||||
function updateCardSelection(checkbox) {
|
||||
const card = checkbox.closest('.s2-api-card');
|
||||
if (checkbox.checked) {
|
||||
card.classList.add('selected');
|
||||
selectedApis.add(checkbox.value);
|
||||
} else {
|
||||
card.classList.remove('selected');
|
||||
selectedApis.delete(checkbox.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Update selected count
|
||||
function updateSelectedCount() {
|
||||
const floatingCartBtn = document.getElementById('floatingCartBtn');
|
||||
const cartCount = document.querySelector('.s2-cart-count');
|
||||
|
||||
if (selectedApis.size > 0) {
|
||||
floatingCartBtn.style.display = 'flex';
|
||||
cartCount.textContent = selectedApis.size;
|
||||
} else {
|
||||
floatingCartBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
updateModalList();
|
||||
updateSelectAllCheckboxState();
|
||||
}
|
||||
|
||||
// Update select all UI visibility and text
|
||||
function updateSelectAllUI() {
|
||||
const selectAllWrapper = document.getElementById('selectAllWrapper');
|
||||
const selectAllText = document.getElementById('selectAllText');
|
||||
|
||||
if (currentFilter === '') {
|
||||
selectAllWrapper.style.display = 'none';
|
||||
} else {
|
||||
selectAllWrapper.style.display = 'flex';
|
||||
selectAllText.textContent = currentServiceName + ' API 전체 선택';
|
||||
}
|
||||
|
||||
updateSelectAllCheckboxState();
|
||||
}
|
||||
|
||||
// Update select all checkbox state based on visible cards
|
||||
function updateSelectAllCheckboxState() {
|
||||
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
|
||||
|
||||
if (visibleCards.length === 0) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.s2-api-checkbox'));
|
||||
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
|
||||
|
||||
if (checkedCount === 0) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else if (checkedCount === visibleCheckboxes.length) {
|
||||
selectAllCheckbox.checked = true;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update modal selected APIs list — selectedApis Set 기준 (패치 2)
|
||||
function updateModalList() {
|
||||
const modalSelectedList = document.getElementById('modalSelectedList');
|
||||
modalSelectedList.innerHTML = '';
|
||||
|
||||
if (selectedApis.size === 0) {
|
||||
modalSelectedList.innerHTML = '<p class="s2-empty-message">선택된 API가 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
selectedApis.forEach(function(apiId) {
|
||||
const card = document.querySelector('.s2-api-card[data-api-id="' + apiId + '"]');
|
||||
const apiName = card ? card.querySelector('.s2-api-card-title').textContent : apiId;
|
||||
|
||||
const apiPill = document.createElement('div');
|
||||
apiPill.className = 's2-api-pill';
|
||||
apiPill.innerHTML = `
|
||||
<span class="s2-api-pill-name">${apiName}</span>
|
||||
<button type="button" class="s2-api-pill-remove" data-value="${apiId}" aria-label="Remove ${apiName}">✕</button>
|
||||
`;
|
||||
modalSelectedList.appendChild(apiPill);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.s2-api-pill-remove').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
const value = this.getAttribute('data-value');
|
||||
selectedApis.delete(value);
|
||||
const checkbox = document.querySelector('.s2-api-checkbox[value="' + value + '"]');
|
||||
if (checkbox) {
|
||||
checkbox.checked = false;
|
||||
updateCardSelection(checkbox);
|
||||
}
|
||||
updateSelectedCount();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function() {
|
||||
const searchTerm = this.value.toLowerCase();
|
||||
|
||||
const apiCards = document.querySelectorAll('.s2-api-card');
|
||||
let visibleCount = 0;
|
||||
apiCards.forEach(function(card) {
|
||||
const apiName = card.getAttribute('data-name');
|
||||
const apiDesc = card.getAttribute('data-desc');
|
||||
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
|
||||
|
||||
if (matchesSearch) {
|
||||
card.style.display = '';
|
||||
visibleCount++;
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('apiResultCount').textContent = visibleCount;
|
||||
updateSelectAllCheckboxState();
|
||||
});
|
||||
}
|
||||
|
||||
// Category tab selection
|
||||
menuTitles.forEach(function(title) {
|
||||
title.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
menuTitles.forEach(t => t.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
|
||||
const groupId = this.getAttribute('data-group');
|
||||
currentFilter = groupId;
|
||||
currentServiceName = this.textContent.trim();
|
||||
|
||||
loadApis(groupId);
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.value = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Category Carousel Scroll
|
||||
const categoryListWrapper = document.getElementById('categoryListWrapper');
|
||||
const btnPrevCategory = document.getElementById('btnPrevCategory');
|
||||
const btnNextCategory = document.getElementById('btnNextCategory');
|
||||
|
||||
if (categoryListWrapper && btnPrevCategory && btnNextCategory) {
|
||||
const scrollAmount = 200;
|
||||
|
||||
btnPrevCategory.addEventListener('click', function() {
|
||||
categoryListWrapper.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
btnNextCategory.addEventListener('click', function() {
|
||||
categoryListWrapper.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
// Toggle buttons visibility/disabled state based on scroll position
|
||||
function updateCarouselButtons() {
|
||||
const scrollLeft = categoryListWrapper.scrollLeft;
|
||||
const maxScrollLeft = categoryListWrapper.scrollWidth - categoryListWrapper.clientWidth;
|
||||
|
||||
btnPrevCategory.disabled = scrollLeft <= 0;
|
||||
btnNextCategory.disabled = scrollLeft >= maxScrollLeft - 1;
|
||||
}
|
||||
|
||||
categoryListWrapper.addEventListener('scroll', updateCarouselButtons);
|
||||
window.addEventListener('resize', updateCarouselButtons);
|
||||
|
||||
// Initial check after loading categories
|
||||
setTimeout(updateCarouselButtons, 150);
|
||||
}
|
||||
|
||||
// Modal control
|
||||
const floatingCartBtn = document.getElementById('floatingCartBtn');
|
||||
const selectedApisModal = document.getElementById('selectedApisModal');
|
||||
const modalOverlay = document.getElementById('modalOverlay');
|
||||
const modalCloseBtn = document.getElementById('modalCloseBtn');
|
||||
const modalCancelBtn = document.getElementById('modalCancelBtn');
|
||||
|
||||
function openModal() {
|
||||
updateModalList(); // 열 때마다 최신 선택 상태로 재빌드 (패치 1)
|
||||
selectedApisModal.style.display = 'flex'; // .s2-modal은 flex 중앙정렬 → block 금지
|
||||
setTimeout(function() {
|
||||
if (modalOverlay) {
|
||||
modalOverlay.classList.add('show');
|
||||
}
|
||||
selectedApisModal.classList.add('show');
|
||||
}, 10);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
if (modalOverlay) {
|
||||
modalOverlay.classList.remove('show');
|
||||
}
|
||||
selectedApisModal.classList.remove('show');
|
||||
|
||||
setTimeout(function() {
|
||||
selectedApisModal.style.display = 'none';
|
||||
}, 300);
|
||||
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
if (floatingCartBtn) {
|
||||
floatingCartBtn.addEventListener('click', openModal);
|
||||
}
|
||||
if (modalOverlay) {
|
||||
modalOverlay.addEventListener('click', closeModal);
|
||||
}
|
||||
if (modalCloseBtn) {
|
||||
modalCloseBtn.addEventListener('click', closeModal);
|
||||
}
|
||||
if (modalCancelBtn) {
|
||||
modalCancelBtn.addEventListener('click', closeModal);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && selectedApisModal.style.display === 'flex') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Select All checkbox event
|
||||
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.addEventListener('change', function() {
|
||||
const isChecked = this.checked;
|
||||
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
|
||||
|
||||
visibleCards.forEach(function(card) {
|
||||
const checkbox = card.querySelector('.s2-api-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.checked = isChecked;
|
||||
updateCardSelection(checkbox);
|
||||
}
|
||||
});
|
||||
|
||||
updateSelectedCount();
|
||||
});
|
||||
}
|
||||
|
||||
// DOM에 렌더되지 않은 선택분을 hidden input으로 주입 — 전송 유실 방지 (패치 3)
|
||||
function syncHiddenSelected() {
|
||||
document.querySelectorAll('input.hidden-selected-api').forEach(el => el.remove());
|
||||
selectedApis.forEach(function(apiId) {
|
||||
if (!document.querySelector('.s2-api-checkbox[value="' + apiId + '"]')) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'selectedApis';
|
||||
input.value = apiId;
|
||||
input.className = 'hidden-selected-api';
|
||||
form.appendChild(input);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Previous step button — 선택 저장 후 step1 복귀
|
||||
const btnPrevStep = document.getElementById('btnPrevStep');
|
||||
if (btnPrevStep) {
|
||||
btnPrevStep.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const saveAction = form.getAttribute('data-save-action');
|
||||
if (saveAction) {
|
||||
form.action = saveAction;
|
||||
}
|
||||
syncHiddenSelected();
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
// Form submit
|
||||
// - Webhook/앱 변경은 API 가 필수(form data-api-required=true)이므로 미선택 시 차단한다.
|
||||
// - 앱 신청(myapikey register)은 선택 사항이라, 미선택 시 확인 팝업으로 한 번 더 묻고 진행한다.
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (selectedApis.size !== 0) {
|
||||
syncHiddenSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
var apiRequired = form.dataset.apiRequired === 'true';
|
||||
if (apiRequired) {
|
||||
e.preventDefault();
|
||||
if (typeof customPopups !== 'undefined' && customPopups.showAlert) {
|
||||
customPopups.showAlert('최소 1개 이상의 API를 선택해주세요.');
|
||||
} else {
|
||||
alert('최소 1개 이상의 API를 선택해주세요.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 미선택 상태로 앱 신청 → 확인 후 진행
|
||||
e.preventDefault();
|
||||
var message = '선택한 API가 없습니다. API 없이 앱을 신청하시겠습니까?';
|
||||
// custom-popups.js 는 top-level const 라 window.customPopups 가 아닌 렉시컬 전역이다.
|
||||
if (typeof customPopups !== 'undefined' && customPopups.showConfirm) {
|
||||
customPopups.showConfirm(message, function (ok) {
|
||||
if (ok) {
|
||||
syncHiddenSelected();
|
||||
form.submit(); // submit() 은 submit 이벤트를 재발생시키지 않음 → 재확인 루프 없음
|
||||
}
|
||||
});
|
||||
} else if (window.confirm(message)) {
|
||||
syncHiddenSelected();
|
||||
form.submit();
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Initialize
|
||||
updateSelectedCount();
|
||||
loadApis('');
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 비밀번호 문자열 정책 라이브 검증 (공용)
|
||||
*
|
||||
* 서버 검증기 PasswordRuleValidator.isValid(= @PasswordRule) 의
|
||||
* "문자열" 규칙을 그대로 클라이언트로 포팅한다. 아이디/휴대전화 포함 여부는
|
||||
* 민감정보 노출을 피하기 위해 서버 검증에만 맡긴다.
|
||||
*
|
||||
* 사용법(마크업 구동):
|
||||
* <ul class="password-policy-checklist" data-password-input="newPassword">
|
||||
* <li data-rule="length" class="is-idle"><span class="policy-icon"></span><span class="policy-text">...</span></li>
|
||||
* ... (rule: length | letter | digit | special | nospace | norepeat | noseq)
|
||||
* </ul>
|
||||
* <input type="password" id="newPassword" ...>
|
||||
*
|
||||
* DOM ready 시 자동으로 스캔하여 대상 input 에 바인딩한다.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
// 3자리 연속(오름/내림) 문자·숫자 검사 — 서버 로직과 동일하게 대문자로 비교
|
||||
function hasSequential(pw) {
|
||||
var s = pw.toUpperCase();
|
||||
for (var i = 0; i < s.length - 2; i++) {
|
||||
var a = s.charCodeAt(i), b = s.charCodeAt(i + 1), c = s.charCodeAt(i + 2);
|
||||
var sameCategory = (a > 47 && c < 58) || (a > 64 && c < 91); // 숫자 0-9 또는 영문 A-Z
|
||||
var consecutive = Math.abs(c - b) === 1 && Math.abs(c - a) === 2;
|
||||
if (sameCategory && consecutive) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 규칙별 판정 함수 (통과=true)
|
||||
var RULES = {
|
||||
length: function (pw) { return pw.length >= 8 && pw.length <= 50; },
|
||||
letter: function (pw) { return /[a-zA-Z]/.test(pw); },
|
||||
digit: function (pw) { return /[0-9]/.test(pw); },
|
||||
special: function (pw) { return /[^A-Za-z0-9_]/.test(pw); }, // 서버 정규식 \W 기준 (밑줄 제외)
|
||||
nospace: function (pw) { return !/\s/.test(pw); },
|
||||
norepeat: function (pw) { return !/(\w)\1\1/.test(pw.toUpperCase()); },
|
||||
noseq: function (pw) { return !hasSequential(pw); }
|
||||
};
|
||||
|
||||
// 전체 문자열 규칙 통과 여부
|
||||
function isValid(pw) {
|
||||
if (!pw) {
|
||||
return false;
|
||||
}
|
||||
for (var key in RULES) {
|
||||
if (RULES.hasOwnProperty(key) && !RULES[key](pw)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 체크리스트(ul) 하나를 대상 input 에 바인딩
|
||||
function bind(input, list) {
|
||||
var $input = (input && input.jquery) ? input : $(input);
|
||||
var $list = (list && list.jquery) ? list : $(list);
|
||||
var $items = $list.find('li[data-rule]');
|
||||
if (!$input.length || !$items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
function update() {
|
||||
var pw = $input.val() || '';
|
||||
$items.each(function () {
|
||||
var $li = $(this);
|
||||
var rule = RULES[$li.attr('data-rule')];
|
||||
if (!rule) {
|
||||
return;
|
||||
}
|
||||
$li.removeClass('is-idle is-pass is-fail');
|
||||
if (pw.length === 0) {
|
||||
$li.addClass('is-idle');
|
||||
} else {
|
||||
$li.addClass(rule(pw) ? 'is-pass' : 'is-fail');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$input.on('input.passwordPolicy', update);
|
||||
update();
|
||||
}
|
||||
|
||||
// 마크업 구동 자동 초기화
|
||||
function init(root) {
|
||||
var $root = root ? $(root) : $(document);
|
||||
$root.find('ul.password-policy-checklist[data-password-input]').each(function () {
|
||||
var $list = $(this);
|
||||
var input = document.getElementById($list.attr('data-password-input'));
|
||||
if (input) {
|
||||
bind(input, $list);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
global.PasswordPolicy = {
|
||||
RULES: RULES,
|
||||
isValid: isValid,
|
||||
bind: bind,
|
||||
init: init
|
||||
};
|
||||
|
||||
$(function () {
|
||||
init(document);
|
||||
});
|
||||
})(window);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -101,8 +101,9 @@ $z-index-dropdown: 100;
|
||||
$z-index-sticky: 200;
|
||||
$z-index-fixed: 300;
|
||||
$z-index-header: 350;
|
||||
$z-index-modal-backdrop: 400;
|
||||
$z-index-modal: 500;
|
||||
// 모달은 헤더(.global-header z-index:1000 하드코딩)보다 항상 위에 떠야 한다
|
||||
$z-index-modal-backdrop: 1040;
|
||||
$z-index-modal: 1050;
|
||||
$z-index-popover: 600;
|
||||
$z-index-tooltip: 700;
|
||||
$z-index-notification: 800;
|
||||
|
||||
@@ -8,20 +8,39 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// FAQ Accordion - Figma Design
|
||||
|
||||
.faq-container {
|
||||
|
||||
|
||||
.table-controls {
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
display: contents;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.faq-accordion {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: $spacing-2xl;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
position: relative;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
transition: $transition-base;
|
||||
|
||||
// Active state - when accordion is open
|
||||
&.active {
|
||||
&:hover {
|
||||
.faq-question {
|
||||
background: #4685ef;
|
||||
border-color: #e8dddd;
|
||||
@@ -33,14 +52,8 @@
|
||||
|
||||
.faq-icon {
|
||||
color: #fff;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.faq-answer {
|
||||
display: block;
|
||||
animation: slideDown 0.3s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -598,6 +598,11 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.md {
|
||||
font-size: 14px;
|
||||
padding: 11px 45px;
|
||||
}
|
||||
|
||||
|
||||
@include respond-to('sm') {
|
||||
font-size: 15px;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// DJBank Custom — Q&A 댓글 영역 (DJPGPT0002)
|
||||
// 설계서: DJPCSQ0300P 참조
|
||||
@@ -160,7 +162,8 @@
|
||||
}
|
||||
|
||||
.djb-comment-form {
|
||||
display: block;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -202,6 +205,17 @@ textarea.djb-comment-input {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
gap: 12px;
|
||||
|
||||
// Let the checkbox area and counter align naturally
|
||||
.djb-comment-private-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.djb-comment-char-counter {
|
||||
@@ -223,6 +237,13 @@ button.djb-comment-submit {
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-end; // Float to the right under flex-direction: column
|
||||
margin-top: 12px;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
width: 100%; // Full width button on mobile
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #f8faff;
|
||||
@@ -248,4 +269,4 @@ button.djb-comment-submit {
|
||||
text-align: center;
|
||||
color: #6B7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// 비밀번호 문자열 정책 라이브 체크리스트 (마이페이지 비밀번호 변경 + 회원가입 공용)
|
||||
.password-policy-checklist {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 12px 0 0 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
margin-bottom: 6px;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.policy-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
|
||||
&::before {
|
||||
content: "\2022"; // •
|
||||
}
|
||||
}
|
||||
|
||||
&.is-idle {
|
||||
color: #888;
|
||||
|
||||
.policy-icon {
|
||||
color: #b5b5b5;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-pass {
|
||||
color: #1a8f4c;
|
||||
|
||||
.policy-icon {
|
||||
color: #1a8f4c;
|
||||
|
||||
&::before {
|
||||
content: "\2714"; // ✔
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-fail {
|
||||
color: #d63a3a;
|
||||
|
||||
.policy-icon {
|
||||
color: #d63a3a;
|
||||
|
||||
&::before {
|
||||
content: "\2716"; // ✖
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.password-policy-note {
|
||||
margin: 10px 0 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
color: #888;
|
||||
}
|
||||
@@ -71,12 +71,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 모바일: 헤더 우측 햄버거(메뉴 버튼) 바로 왼쪽에 나란히 배치.
|
||||
// right = 헤더 padding(20px) + 햄버거 폭(~32px) + 간격(~20px) ≈ 72px
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
// 가로 여유가 있을 때만 노출.
|
||||
// 헤더 .container(max-width:1280px, 중앙정렬)의 우측 여백에 위젯(폭 ~200px)이 들어갈 만큼
|
||||
// 넓은 화면(약 1700px 이상)에서만 표시 → 그 외에는 헤더 우측 메뉴(사용자명·로그아웃·마이페이지)를 가리므로 숨김.
|
||||
// (PC 전용: 모바일 ≤768px 도 당연히 숨김)
|
||||
@media (max-width: 1699px) {
|
||||
.session-float {
|
||||
top: 12px;
|
||||
right: 72px;
|
||||
padding: 4px 10px;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -404,6 +406,126 @@
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------------
|
||||
// Inquiry List Specific Overrides (Djb Q&A List Design)
|
||||
// -----------------------------------------------------------------------------
|
||||
.inquiry-list-container {
|
||||
.table-controls {
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
gap: 24px !important;
|
||||
|
||||
.search-field {
|
||||
order: 1 !important;
|
||||
}
|
||||
|
||||
.total-count {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
order: 2 !important;
|
||||
text-align: left;
|
||||
font-size: 16px;
|
||||
color: #000;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1.5px solid #212529;
|
||||
margin-bottom: 0;
|
||||
|
||||
.inquiry-visibility-notice {
|
||||
margin-left: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inquiry-list-container {
|
||||
|
||||
.board-table {
|
||||
.board-table-row {
|
||||
.row-cell {
|
||||
&--number {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
&--title {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 500px;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.notice-title-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
|
||||
.file-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-right: 6px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.inquiry-private-label {
|
||||
color: #8c959f;
|
||||
font-style: italic;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.inquiry-subject-text {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 240px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.inquiry-comment-count {
|
||||
color: #0049b4;
|
||||
font-weight: 600;
|
||||
margin-left: 6px;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--writer {
|
||||
width: 120px;
|
||||
|
||||
.inquiry-writer-org {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&--status {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
&--views {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
&--date {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// List Table - Modern design with colored header (Figma: 984-2173)
|
||||
// Used in: User management, API key list, etc.
|
||||
@@ -813,6 +935,7 @@
|
||||
// 공지사항 모바일 디자인 - 검색창, 목록, 페이지네이션
|
||||
// -----------------------------------------------------------------------------
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
|
||||
// 검색 필드 - Figma: 335px × 40px, border-radius 8px
|
||||
.search-field {
|
||||
display: flex;
|
||||
@@ -995,7 +1118,8 @@
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
i, svg {
|
||||
i,
|
||||
svg {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
@@ -1019,4 +1143,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);
|
||||
|
||||
@@ -1336,4 +1336,4 @@ body.design-survey-active {
|
||||
.header-user-info {
|
||||
display: none; // Hide on mobile, use drawer instead
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@
|
||||
@use 'components/breadcrumb' as *;
|
||||
@use 'components/test-env-notice' as *;
|
||||
@use 'components/djb-inquiry-comments' as *;
|
||||
@use 'components/password-policy' as *;
|
||||
|
||||
// 5. Page-specific styles
|
||||
@use 'pages/index' as *;
|
||||
@@ -66,6 +67,7 @@
|
||||
@use 'pages/terms-agreements' as *;
|
||||
@use 'pages/service' as *;
|
||||
@use 'pages/api-statistics' as *;
|
||||
@use 'pages/webhook' as *;
|
||||
|
||||
// 6. Themes
|
||||
@use 'themes/dark' as *;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -255,11 +255,19 @@
|
||||
max-width: 1200px;
|
||||
margin: 40px auto 100px;
|
||||
font-family: 'Spoqa Han Sans Neo', 'Noto Sans CJK KR', sans-serif;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin: 20px auto 50px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-management-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-management-header {
|
||||
@@ -268,6 +276,14 @@
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
font-size: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
@@ -318,6 +334,14 @@
|
||||
color: inherit;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
height: auto;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0px 6px 15px rgba(192, 192, 192, 0.35);
|
||||
@@ -335,12 +359,23 @@
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,6 +385,10 @@
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-card-header {
|
||||
@@ -366,6 +405,10 @@
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-card-badge-container {
|
||||
@@ -391,6 +434,11 @@
|
||||
color: #ef9546;
|
||||
}
|
||||
|
||||
&.badge-requested {
|
||||
background: #ffebeb;
|
||||
color: #f43f5e;
|
||||
}
|
||||
|
||||
&.badge-approved {
|
||||
background: #1b4ab7;
|
||||
color: #ffffff;
|
||||
@@ -411,6 +459,10 @@
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-card-footer-row {
|
||||
@@ -419,6 +471,12 @@
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-card-expected-date {
|
||||
@@ -427,7 +485,13 @@
|
||||
margin-left: 16px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
margin-left: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-card-expected-date {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
@@ -456,4 +520,4 @@
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2938,6 +2938,10 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: 'Spoqa Han Sans Neo', 'Noto Sans KR', sans-serif;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
font-size: 23px !important;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 진행 단계 카드 ──
|
||||
@@ -3015,6 +3019,16 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
||||
flex-shrink: 0;
|
||||
color: #b0bec5; // SVG currentColor 비활성 색상
|
||||
|
||||
img {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
@@ -3032,13 +3046,13 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3467,6 +3481,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
||||
@media (max-width: 768px) {
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3707,7 +3722,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0px 8px 12px rgba(194, 194, 194, 0.4);
|
||||
background: linear-gradient(to bottom, #50caff, #2088ff);
|
||||
border-color: transparent;
|
||||
border: none;
|
||||
|
||||
.s2-api-card-badge {
|
||||
background-color: #d8f0ff;
|
||||
@@ -4027,7 +4042,8 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
||||
}
|
||||
|
||||
// ── Floating Cart Button ──
|
||||
.s2-floating-cart {
|
||||
// @at-root: body 직속(pagePopups)으로 렌더되므로 step2-wrap 스코프 밖 전역 규칙으로 컴파일
|
||||
@at-root .s2-floating-cart {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
right: 40px;
|
||||
@@ -4114,7 +4130,8 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
||||
}
|
||||
|
||||
// ── Selected APIs Modal ──
|
||||
.s2-modal {
|
||||
// @at-root: body 직속(pagePopups)으로 렌더되므로 step2-wrap 스코프 밖 전역 규칙으로 컴파일
|
||||
@at-root .s2-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -4404,4 +4421,4 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -828,6 +828,13 @@
|
||||
gap: 10px;
|
||||
margin-top: 40px;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
flex-direction: column; // Stack vertically on mobile
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
width: 100px;
|
||||
height: 48px;
|
||||
@@ -844,6 +851,11 @@
|
||||
&:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
width: 100%;
|
||||
order: 2; // Position Cancel at the bottom
|
||||
}
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
@@ -860,6 +872,12 @@
|
||||
&:hover {
|
||||
background-color: darken(#2a69de, 5%);
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
width: 100%;
|
||||
order: 1; // Position Submit at the top
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,7 @@
|
||||
gap: 15px;
|
||||
|
||||
@media (max-width: $breakpoint-md) {
|
||||
flex-direction: column;
|
||||
// flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -158,6 +158,28 @@
|
||||
color: #0a4ea3;
|
||||
border: 1px solid #b3d4ff;
|
||||
}
|
||||
|
||||
&--test {
|
||||
background: #e6fffa;
|
||||
color: #0d9488;
|
||||
border: 1px solid #99f6e4;
|
||||
}
|
||||
|
||||
&--normal {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
|
||||
&.lg {
|
||||
height: 28px;
|
||||
width: auto;
|
||||
min-width: 50px;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-detail-title {
|
||||
@@ -222,11 +244,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 +274,115 @@
|
||||
}
|
||||
|
||||
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;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
strong {
|
||||
color: #0a4ea3;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.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 +452,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
|
||||
|
||||
@@ -14,61 +14,144 @@
|
||||
@include respond-to('sm') {
|
||||
margin-bottom: $spacing-3xl;
|
||||
}
|
||||
}
|
||||
|
||||
.page-header-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
// Recent applications accordion block styling
|
||||
.recent-apps {
|
||||
margin-bottom: 70px;
|
||||
|
||||
.recent-apps-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: $font-size-3xl;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $text-dark;
|
||||
margin-bottom: $spacing-md;
|
||||
.recent-apps-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.recent-apps-item {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
&.active {
|
||||
border-color: #2a69de;
|
||||
box-shadow: 0 4px 12px rgba(42, 105, 222, 0.08);
|
||||
|
||||
.recent-apps-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recent-apps-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-md;
|
||||
width: 100%;
|
||||
padding: 8px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
gap: 16px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.recent-apps-date {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
flex-shrink: 0;
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.recent-apps-subject {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.recent-apps-arrow {
|
||||
color: #64748b;
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recent-apps-body {
|
||||
display: none;
|
||||
padding: 20px;
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #334155;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: $font-size-3xl;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $text-dark;
|
||||
margin-bottom: $spacing-md;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-md;
|
||||
|
||||
@include respond-to('sm') {
|
||||
font-size: $font-size-2xl;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
i {
|
||||
color: $primary-blue;
|
||||
font-size: $font-size-3xl;
|
||||
|
||||
@include respond-to('sm') {
|
||||
font-size: $font-size-2xl;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
i {
|
||||
color: $primary-blue;
|
||||
font-size: $font-size-3xl;
|
||||
|
||||
@include respond-to('sm') {
|
||||
font-size: $font-size-2xl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-description {
|
||||
font-size: $font-size-lg;
|
||||
color: $text-gray;
|
||||
line-height: $line-height-normal;
|
||||
.page-description {
|
||||
font-size: $font-size-lg;
|
||||
color: $text-gray;
|
||||
line-height: $line-height-normal;
|
||||
|
||||
@include respond-to('sm') {
|
||||
font-size: $font-size-base;
|
||||
}
|
||||
@include respond-to('sm') {
|
||||
font-size: $font-size-base;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: $primary-blue;
|
||||
font-weight: $font-weight-semibold;
|
||||
position: relative;
|
||||
.highlight {
|
||||
color: $primary-blue;
|
||||
font-weight: $font-weight-semibold;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,27 +277,56 @@
|
||||
// .form-actions moved to components/_forms.scss
|
||||
|
||||
// File Upload Wrapper Override for Partnership Page
|
||||
.partnership-card .file-upload-wrapper {
|
||||
.file-name-display {
|
||||
&.has-file {
|
||||
color: $text-dark;
|
||||
background: rgba($primary-blue, 0.05);
|
||||
border-color: $primary-blue;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
.file-upload-btn {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.partnership-card,
|
||||
.service-content {
|
||||
.file-upload-wrapper {
|
||||
.file-name-display {
|
||||
order: 2;
|
||||
&.has-file {
|
||||
color: $text-dark;
|
||||
background: rgba($primary-blue, 0.05);
|
||||
border-color: $primary-blue;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.file-remove-btn {
|
||||
order: 3;
|
||||
.file-upload-btn {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
height: 55px;
|
||||
width: 120px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
background-color: #ffffff;
|
||||
color: #4685ef;
|
||||
border: 1px solid #2a69de;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #f8faff;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
.file-upload-btn {
|
||||
order: 1;
|
||||
width: 100%; // Make full-width on mobile
|
||||
}
|
||||
|
||||
.file-name-display {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.file-remove-btn {
|
||||
order: 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,9 +363,10 @@
|
||||
}
|
||||
|
||||
.file-upload-wrapper {
|
||||
|
||||
.file-upload-btn,
|
||||
.file-remove-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,8 +139,14 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
|
||||
flex-direction: column;
|
||||
gap: 40px;
|
||||
min-width: 0; // Prevent flex item from expanding horizontally beyond parent
|
||||
|
||||
&.n-gap {
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.service-sidebar {
|
||||
width: 213px;
|
||||
flex-shrink: 0;
|
||||
@@ -227,13 +233,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 +1043,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 +1114,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 +1289,7 @@ $o2leg-err-fg: #a23b3b;
|
||||
background: $o2leg-text-dark;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
margin-top: 24px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
|
||||
@@ -9,11 +9,9 @@
|
||||
|
||||
// User Management Container (List Page)
|
||||
.user-management-container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: $spacing-3xl 0;
|
||||
margin-bottom: $spacing-2xl;
|
||||
min-height: 750px; // Ensure the footer doesn't float when content is short
|
||||
min-height: 650px; // Ensure the footer doesn't float when content is short
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
padding: $spacing-lg $spacing-md;
|
||||
@@ -109,12 +107,6 @@
|
||||
grid-template-columns: 24px 112px 1fr 100px 100px 287px;
|
||||
padding: 0 29px; // Match standard board-table padding
|
||||
gap: 10px;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
display: flex;
|
||||
padding: 15px;
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.header-cell,
|
||||
@@ -505,198 +497,134 @@
|
||||
display: contents;
|
||||
|
||||
.total-count {
|
||||
order: 1;
|
||||
text-align: left;
|
||||
font-size: 16px;
|
||||
color: $black;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1.5px solid #212529;
|
||||
order: 1 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
width: 100% !important;
|
||||
text-align: left !important;
|
||||
font-size: 16px !important;
|
||||
color: $black !important;
|
||||
padding-bottom: 6px !important;
|
||||
border-bottom: 1.5px solid #212529 !important;
|
||||
margin-bottom: 0 !important;
|
||||
|
||||
strong {
|
||||
color: #0049b4;
|
||||
font-weight: $font-weight-bold;
|
||||
font-size: 16px;
|
||||
color: #0049b4 !important;
|
||||
font-weight: $font-weight-bold !important;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 이용자 추가 버튼 - 테이블 아래로 이동
|
||||
// 이용자 추가 버튼 (검색창 위치와 동일하게 총건수 바로 아래에 노출하도록 순서 조정)
|
||||
.search-box {
|
||||
order: 4;
|
||||
width: 100%;
|
||||
order: 2 !important;
|
||||
width: 100% !important;
|
||||
margin-top: 10px !important;
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 100% !important;
|
||||
height: 44px !important;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// List Table - 모바일 디자인 (코너 라운드 적용)
|
||||
.list-table {
|
||||
order: 2;
|
||||
background: $white;
|
||||
border-radius: $border-radius-lg;
|
||||
overflow: visible; // dropdown이 보이도록 변경
|
||||
// List Table - 모바일 디자인 (Q&A 세로 카드형 레이아웃 적용)
|
||||
.board-table--user-list {
|
||||
order: 3 !important;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
overflow: visible;
|
||||
|
||||
// Header - 모바일에서 표시 (Figma: 40px 높이, #3BA4ED 배경)
|
||||
.list-table-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 8px;
|
||||
background: #3BA4ED;
|
||||
border-radius: $border-radius-md $border-radius-md 0 0;
|
||||
|
||||
.header-cell {
|
||||
font-size: 14px;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $white;
|
||||
padding: 0 4px;
|
||||
justify-content: center;
|
||||
|
||||
// NO 컬럼 - 모바일에서 숨김
|
||||
&:nth-child(1) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// 성명 컬럼 - 60px
|
||||
&:nth-child(2) {
|
||||
width: 60px !important;
|
||||
min-width: 60px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
// 이메일 아이디 컬럼 - flex 1
|
||||
&:nth-child(3) {
|
||||
flex: 1 !important;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
// 권한 컬럼 - 39px
|
||||
&:nth-child(4) {
|
||||
width: 39px !important;
|
||||
min-width: 39px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
// 계정상태 컬럼 - 61px
|
||||
&:nth-child(5) {
|
||||
width: 61px !important;
|
||||
min-width: 61px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
// 설정 컬럼 - 모바일에서 표시 (40px)
|
||||
&:nth-child(6) {
|
||||
display: flex !important;
|
||||
width: 40px !important;
|
||||
min-width: 40px;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
.board-table-header {
|
||||
display: none !important; // 헤더는 모바일에서 숨김
|
||||
}
|
||||
|
||||
// Body
|
||||
.list-table-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// Row - 모바일 디자인 (Figma: 40px 높이)
|
||||
.list-table-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 8px;
|
||||
gap: 0;
|
||||
position: relative; // dropdown positioning
|
||||
|
||||
// 짝수/홀수 배경색
|
||||
&:nth-child(even) {
|
||||
background-color: #EDF5FD;
|
||||
}
|
||||
|
||||
&:nth-child(odd) {
|
||||
background-color: $white;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($primary-blue, 0.08);
|
||||
}
|
||||
.board-table-row {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: flex-start !important;
|
||||
height: auto !important;
|
||||
padding: 20px 15px !important;
|
||||
gap: 12px !important;
|
||||
border-bottom: 1px solid #e2e8f0 !important;
|
||||
background: $white;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.row-cell {
|
||||
font-size: 13px;
|
||||
font-weight: $font-weight-regular;
|
||||
color: #212529;
|
||||
padding: 0 4px;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex !important;
|
||||
justify-content: flex-start !important;
|
||||
width: 100% !important;
|
||||
font-size: 14px !important;
|
||||
color: #212529 !important;
|
||||
padding: 0 !important;
|
||||
white-space: normal !important;
|
||||
|
||||
// data-label 숨김
|
||||
&::before {
|
||||
display: none;
|
||||
content: attr(data-label) !important;
|
||||
display: inline-block !important;
|
||||
font-weight: 500 !important;
|
||||
color: #64748b !important;
|
||||
margin-right: 15px !important;
|
||||
min-width: 70px !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
// NO 컬럼 - 모바일에서 숨김
|
||||
&:nth-child(1) {
|
||||
display: none;
|
||||
// NO 컬럼 - 모바일 숨김
|
||||
&[data-label="NO"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// 성명 컬럼 - 60px
|
||||
&:nth-child(2) {
|
||||
width: 60px !important;
|
||||
min-width: 60px;
|
||||
flex: none;
|
||||
// 성명 컬럼 - 제목처럼 크게 노출 (라벨 제거)
|
||||
&[data-label="성명"] {
|
||||
font-size: 16px !important;
|
||||
font-weight: 600 !important;
|
||||
color: #0f172a !important;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #212529;
|
||||
color: #0f172a !important;
|
||||
font-weight: 600 !important;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
color: #2a69de !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 이메일 아이디 컬럼 - flex 1
|
||||
&:nth-child(3) {
|
||||
flex: 1 !important;
|
||||
min-width: 80px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
// 권한 컬럼 - 39px
|
||||
&:nth-child(4) {
|
||||
width: 39px !important;
|
||||
min-width: 39px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
// 계정상태 컬럼 - 61px
|
||||
&:nth-child(5) {
|
||||
width: 61px !important;
|
||||
min-width: 61px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
// 설정 컬럼 (row-actions) - 모바일에서 표시 (40px)
|
||||
&:nth-child(6),
|
||||
// 설정 버튼 배치 (액션이 있을 때만 경계점선 표시)
|
||||
&.row-actions {
|
||||
display: flex !important;
|
||||
width: 40px !important;
|
||||
min-width: 40px;
|
||||
flex: none;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
margin-top: 0;
|
||||
padding-top: 0 !important;
|
||||
border-top: none;
|
||||
justify-content: flex-end !important;
|
||||
|
||||
// row-actions 클래스 표시
|
||||
.row-actions {
|
||||
display: flex !important;
|
||||
&.has-actions {
|
||||
margin-top: 8px;
|
||||
padding-top: 12px !important;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.actions-desktop {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.actions-mobile {
|
||||
display: flex !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -718,9 +646,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination - 모바일 디자인 (테이블 다음, 버튼 이전)
|
||||
// Pagination - 모바일 디자인 (테이블 다음)
|
||||
.pagination {
|
||||
order: 3;
|
||||
order: 4 !important;
|
||||
gap: 13px;
|
||||
padding: $spacing-lg 0;
|
||||
|
||||
@@ -1160,4 +1088,4 @@
|
||||
transform: translateY(4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user