diff --git a/build.gradle b/build.gradle
index 90b7ea6..e0ffd74 100644
--- a/build.gradle
+++ b/build.gradle
@@ -80,9 +80,10 @@ dependencies {
// exclude group: 'commons-collections', module: 'commons-collections'
}
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
- implementation 'com.fasterxml.jackson.core:jackson-core:2.15.3'
- implementation 'com.fasterxml.jackson.core:jackson-annotations:2.15.3'
- implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.3'
+ // WS-2026-0003 (jackson-core async parser DoS, CVSS 7.5) — 2.18.6 에서 수정. JDK8 호환.
+ implementation 'com.fasterxml.jackson.core:jackson-core:2.18.6'
+ implementation 'com.fasterxml.jackson.core:jackson-annotations:2.18.6'
+ implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.6'
implementation group: 'org.apache.velocity', name: 'velocity-engine-core', version: '2.3'
diff --git a/src/main/java/com/eactive/apim/portal/apps/apis/controller/ApiController.java b/src/main/java/com/eactive/apim/portal/apps/apis/controller/ApiController.java
index 8a139c9..d085aaa 100644
--- a/src/main/java/com/eactive/apim/portal/apps/apis/controller/ApiController.java
+++ b/src/main/java/com/eactive/apim/portal/apps/apis/controller/ApiController.java
@@ -64,6 +64,12 @@ public class ApiController {
Map searchResult = apiSearchFacade.searchApis(new ApiGroupSearch());
+ // 상세 타이틀에 노출할 현재 API의 그룹명 세팅(selectDetail은 apiGroupName을 채우지 않음)
+ ApiServiceDTO apiGroup = apiServiceService.findApiServiceByApiId(id);
+ if (apiGroup != null) {
+ api.setApiGroupName(apiGroup.getGroupName());
+ }
+
model.addAttribute("apiSpecInfo", api);
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
model.addAttribute("services", searchResult.get("services"));
diff --git a/src/main/java/com/eactive/apim/portal/apps/apis/filter/APISender.java b/src/main/java/com/eactive/apim/portal/apps/apis/filter/APISender.java
index 1673573..f511e67 100644
--- a/src/main/java/com/eactive/apim/portal/apps/apis/filter/APISender.java
+++ b/src/main/java/com/eactive/apim/portal/apps/apis/filter/APISender.java
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.apps.apis.filter;
+import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import java.io.BufferedReader;
import java.io.DataOutputStream;
@@ -37,13 +38,18 @@ public class APISender {
public String requestPost(String uri, String requestBody) throws IOException {
+ if (logger.isDebugEnabled()) {
+ logger.debug("APISender POST(json) 요청 - uri={}, bodyLen={}, body={}",
+ uri, requestBody == null ? 0 : requestBody.length(), StringMaskingUtil.maskFormBody(requestBody));
+ }
+
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
String response = getResponse(connection);
connection.disconnect();
if (logger.isDebugEnabled()) {
- logger.debug(response);
+ logger.debug("APISender POST(json) 응답 - uri={}, response={}", uri, response);
}
return response;
}
@@ -85,11 +91,15 @@ public class APISender {
}
connection.setDoOutput(true);
+ if (logger.isDebugEnabled()) {
+ logger.debug("APISender GET 요청 - uri={}", appendUriAndParams(uri, params));
+ }
+
String response = getResponse(connection);
connection.disconnect();
if (logger.isDebugEnabled()) {
- logger.debug(response);
+ logger.debug("APISender GET 응답 - uri={}, response={}", uri, response);
}
return response;
}
@@ -110,6 +120,12 @@ public class APISender {
}
connection.setDoOutput(true);
+ if (logger.isDebugEnabled()) {
+ // client_secret 등 민감 파라미터는 마스킹. body 비어있으면 상위에서 본문 전송 유실.
+ logger.debug("APISender POST 요청 - uri={}, bodyLen={}, body={}",
+ uri, requestBody == null ? 0 : requestBody.length(), StringMaskingUtil.maskFormBody(requestBody));
+ }
+
try (DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream())) {
byte[] requestBodyBytes = requestBody.getBytes(StandardCharsets.UTF_8);
outputStream.write(requestBodyBytes);
@@ -120,7 +136,7 @@ public class APISender {
connection.disconnect();
if (logger.isDebugEnabled()) {
- logger.debug(response);
+ logger.debug("APISender POST 응답 - uri={}, response={}", uri, response);
}
return response;
}
diff --git a/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterFilter.java b/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterFilter.java
index c5ae153..c7dfd23 100644
--- a/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterFilter.java
+++ b/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterFilter.java
@@ -4,12 +4,13 @@ package com.eactive.apim.portal.apps.apis.filter;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.common.util.ApplicationContextUtil;
+import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
-import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
import java.io.BufferedReader;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
+import java.net.URLEncoder;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
@@ -72,6 +73,8 @@ public class ApiTesterFilter implements Filter {
String auditId = ApiTesterAuditLogger.newAuditId();
long auditStart = System.currentTimeMillis();
String auditType = "-";
+ // 실제 프록시 호출 대상 URL (mock 은 mockUrl, 토큰 GW 는 base-url+token-path 로 original-url 과 다를 수 있음) — 오류 로그용
+ String proxyTarget = null;
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
if (url == null || url.trim().isEmpty()) {
@@ -86,22 +89,24 @@ public class ApiTesterFilter implements Filter {
// 반환하기 위해 try 로 감싼다.
try {
- // 게이트웨이 모드에 따라 OAuth 토큰 발급 요청을 mock 또는 실 게이트웨이 forward 로 분기 (DJPGPT0001)
+ // 토큰 발급 분기는 전역 게이트웨이 모드가 아니라 "요청 URL 경로"로 판단한다 (API 별 responseType 기반).
+ // - mock API → 프론트가 포탈 mock 토큰 경로(/api/v1/oauth/token)로 요청 → 즉시 mock 토큰 발급
+ // - gw API → 프론트가 실 GW 토큰 경로(token-path)로 요청 → 실 게이트웨이 forward
DjbTestbedGatewayProperty gatewayProperty = ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class);
- DjbGatewayMode gatewayMode = gatewayProperty.resolveGatewayMode();
- boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
- || url.contains(gatewayProperty.tokenPath());
+ boolean mockTokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH);
+ boolean tokenRequest = mockTokenRequest || url.contains(gatewayProperty.tokenPath());
// 본문은 한 번만 읽어 프록시 forward 와 감사 로그에 함께 사용 (GET 이면 빈 문자열)
String requestBody = readBody(httpServletRequest);
- ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url, gatewayMode.name(), tokenRequest, requestBody);
+ ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url,
+ mockTokenRequest ? "MOCK_TOKEN" : "GW", tokenRequest, requestBody);
if (tokenRequest) {
String body = requestBody;
- if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
+ if (mockTokenRequest) {
auditType = "TOKEN_MOCK";
- // PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
+ // mock 응답유형 API: 고정 mock 토큰 즉시 발급 (Secret 검증 없음)
Map params = new HashMap<>();
String[] pairs = body.split("&");
for (String pair : pairs) {
@@ -130,6 +135,12 @@ public class ApiTesterFilter implements Filter {
headers.put("Content-Type", "application/x-www-form-urlencoded");
headers.put("Accept", "application/json");
String target = gatewayProperty.baseUrl() + gatewayProperty.tokenPath();
+ proxyTarget = target;
+ if (logger.isDebugEnabled()) {
+ // client_secret 은 마스킹. body 가 비면 프론트→프록시 전송 유실, client_id 없으면 GW "client not found" 원인.
+ logger.debug("TOKEN_GW forward - auditId={}, target={}, bodyLen={}, body={}",
+ auditId, target, body.length(), StringMaskingUtil.maskFormBody(body));
+ }
String tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
response.setContentType("application/json");
@@ -191,6 +202,15 @@ public class ApiTesterFilter implements Filter {
paramMap = extractQueryParams(url);
}
+ proxyTarget = targetUri;
+ if (logger.isDebugEnabled()) {
+ // GW SERVICE_NOT_FOUND(어댑터 URI 미등록)·AUTH_FAIL 진단용:
+ // 스펙 식별/응답유형, 실제 forward 대상, 전달 헤더(민감값 마스킹), 본문 길이를 남긴다.
+ logger.debug("{} forward - auditId={}, apiId={}, apiUrl={}, apiMethod={}, responseType={}, originalUrl={}, target={}, bodyLen={}, headers={}",
+ auditType, auditId, apiSpecInfoDto.getApiId(), apiSpecInfoDto.getApiUrl(),
+ apiSpecInfoDto.getApiMethod(), responseType, url, targetUri,
+ requestBody == null ? 0 : requestBody.length(), maskHeaders(headers));
+ }
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
String responseStr;
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
@@ -198,23 +218,34 @@ public class ApiTesterFilter implements Filter {
} else {
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
}
+ if (logger.isDebugEnabled()) {
+ logger.debug("{} response - auditId={}, target={}, respLen={}, preview={}",
+ auditType, auditId, targetUri,
+ responseStr == null ? 0 : responseStr.length(), previewOf(responseStr));
+ }
response.setContentType("application/json");
response.getWriter().println(responseStr);
}
} catch (java.net.SocketTimeoutException e) {
// 연결/응답 타임아웃 (djb.gateway.timeout 초과)
- logger.warn("테스트베드 프록시 타임아웃: {}", e.getMessage());
+ logger.warn("테스트베드 프록시 타임아웃 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
+ auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
+ System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage());
writeJson(response, HttpServletResponse.SC_GATEWAY_TIMEOUT,
"{\"error\":\"게이트웨이 응답 시간 초과(timeout)\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} catch (IOException e) {
- // 연결 실패 등 네트워크 오류
- logger.error("테스트베드 프록시 호출 실패", e);
+ // 연결 실패 등 네트워크 오류 (ConnectException: 대상 다운/포트 닫힘, UnknownHostException: 주소 오기입 등)
+ logger.error("테스트베드 프록시 호출 실패 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
+ auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
+ System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage(), e);
writeJson(response, HttpServletResponse.SC_BAD_GATEWAY,
- "{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
+ "{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getClass().getSimpleName() + ": " + e.getMessage()) + "\"}");
} catch (Exception e) {
// 그 외 예기치 못한 오류도 JSON 으로 반환
- logger.error("테스트베드 프록시 처리 오류", e);
+ logger.error("테스트베드 프록시 처리 오류 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
+ auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
+ System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage(), e);
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} finally {
@@ -223,7 +254,14 @@ public class ApiTesterFilter implements Filter {
}
}
- /** 요청 본문 전체를 문자열로 읽는다. */
+ /**
+ * 요청 본문 전체를 문자열로 읽는다.
+ *
+ * form-urlencoded 요청에서 상위 필터(XSS/CSRF/Multipart 등)가 이미 {@code getParameter*} 로
+ * 본문 스트림을 소비했으면 {@code getReader()} 는 빈 문자열을 반환한다. 이 경우 토큰 발급 본문
+ * (grant_type/client_id/client_secret/scope)이 게이트웨이로 전달되지 않아 "client not found" 로
+ * 실패하므로, 파싱된 파라미터 맵으로 본문을 재구성해 복원한다.
+ */
private String readBody(HttpServletRequest request) throws IOException {
StringBuilder sb = new StringBuilder();
BufferedReader reader = request.getReader();
@@ -231,6 +269,39 @@ public class ApiTesterFilter implements Filter {
while ((line = reader.readLine()) != null) {
sb.append(line);
}
+ if (sb.length() == 0 && isFormUrlEncoded(request)) {
+ String rebuilt = rebuildFormBodyFromParams(request);
+ if (!rebuilt.isEmpty()) {
+ logger.debug("요청 본문이 비어 파라미터 맵으로 재구성 - body={}", StringMaskingUtil.maskFormBody(rebuilt));
+ return rebuilt;
+ }
+ }
+ return sb.toString();
+ }
+
+ /** Content-Type 이 application/x-www-form-urlencoded 계열인지. */
+ private boolean isFormUrlEncoded(HttpServletRequest request) {
+ String contentType = request.getContentType();
+ return contentType != null && contentType.toLowerCase().contains("application/x-www-form-urlencoded");
+ }
+
+ /** 파싱된 파라미터 맵을 form-urlencoded 본문 문자열로 재구성 (본문 스트림이 이미 소비된 경우 복원용). */
+ private String rebuildFormBodyFromParams(HttpServletRequest request) {
+ StringBuilder sb = new StringBuilder();
+ for (Map.Entry entry : request.getParameterMap().entrySet()) {
+ for (String value : entry.getValue()) {
+ if (sb.length() > 0) {
+ sb.append('&');
+ }
+ try {
+ sb.append(URLEncoder.encode(entry.getKey(), "UTF-8"))
+ .append('=')
+ .append(URLEncoder.encode(value == null ? "" : value, "UTF-8"));
+ } catch (java.io.UnsupportedEncodingException e) {
+ sb.append(entry.getKey()).append('=').append(value == null ? "" : value);
+ }
+ }
+ }
return sb.toString();
}
@@ -250,6 +321,27 @@ public class ApiTesterFilter implements Filter {
}
/** 상태코드 + JSON 본문 응답. */
+ /** forward 헤더 debug 출력용 — 민감 헤더(토큰/쿠키 등)는 StringMaskingUtil 로 마스킹. */
+ private String maskHeaders(Map headers) {
+ StringBuilder sb = new StringBuilder("{");
+ for (Map.Entry e : headers.entrySet()) {
+ if (sb.length() > 1) {
+ sb.append(", ");
+ }
+ sb.append(e.getKey()).append(':').append(StringMaskingUtil.maskHeaderValue(e.getKey(), e.getValue()));
+ }
+ return sb.append('}').toString();
+ }
+
+ /** 응답 body debug 프리뷰 — 앞 300자까지만 (개행 제거). */
+ private String previewOf(String body) {
+ if (body == null) {
+ return "null";
+ }
+ String flat = body.replaceAll("\\s+", " ").trim();
+ return flat.length() > 300 ? flat.substring(0, 300) + "…" : flat;
+ }
+
private void writeJson(ServletResponse response, int status, String json) throws IOException {
((HttpServletResponse) response).setStatus(status);
response.setContentType("application/json");
diff --git a/src/main/java/com/eactive/apim/portal/apps/app/controller/MyAppController.java b/src/main/java/com/eactive/apim/portal/apps/app/controller/MyAppController.java
index c5c6a3c..40727cf 100644
--- a/src/main/java/com/eactive/apim/portal/apps/app/controller/MyAppController.java
+++ b/src/main/java/com/eactive/apim/portal/apps/app/controller/MyAppController.java
@@ -13,6 +13,9 @@ import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
import com.eactive.apim.portal.apps.app.service.AdminGatewayClient;
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
+import com.eactive.apim.portal.apps.auth.twofactor.StepUpProtectedPaths;
+import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
+import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.ApiServiceHelper;
import com.eactive.apim.portal.common.util.SecurityUtil;
@@ -49,7 +52,7 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Slf4j
@Controller
-@RequestMapping("/myapikey")
+@RequestMapping("/clients")
@RequiredArgsConstructor
@SessionAttributes({"apiKeyRegistration", "apiKeyModification"})
public class MyAppController {
@@ -82,6 +85,8 @@ public class MyAppController {
private final ApiServiceHelper apiServiceHelper;
private final FileTypeDetector fileTypeDetector;
private final AdminGatewayClient adminGatewayClient;
+ private final TwoFactorService twoFactorService;
+ private final TwoFactorProperties twoFactorProperties;
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
@@ -113,14 +118,14 @@ public class MyAppController {
@Secured("ROLE_APP")
public ModelAndView appRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
if (id == null) {
- return new ModelAndView("redirect:/myapikey");
+ return new ModelAndView("redirect:/clients");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
AppRequestDTO appRequest = appServiceFacade.getAppRequestById(id, user.getPortalOrg());
if (appRequest == null) {
- return new ModelAndView("redirect:/myapikey");
+ return new ModelAndView("redirect:/clients");
}
// API 목록 조회 및 설정
@@ -151,14 +156,14 @@ public class MyAppController {
@Secured("ROLE_APP")
public ModelAndView credentialDetail(@RequestParam(value = "id", required = false) String id, Model model) {
if (id == null) {
- return new ModelAndView("redirect:/myapikey");
+ return new ModelAndView("redirect:/clients");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), id);
if (apiKey == null) {
- return new ModelAndView("redirect:/myapikey");
+ return new ModelAndView("redirect:/clients");
}
// API 목록에 서비스 정보 추가
@@ -318,10 +323,12 @@ public class MyAppController {
}
/**
- * Client Secret을 비밀번호 확인 후 1회 노출하고 즉시 DB에서 물리 삭제합니다.
+ * Client Secret을 1회 노출하고 즉시 DB에서 물리 삭제합니다.
+ * 본인 확인은 step-up 2FA({@link StepUpProtectedPaths#REVEAL_SECRET} 인터셉터 가드)가 담당하며,
+ * 통과권 없이 진입하면 401(stepUpRequired) 로 차단됩니다.
* 보안 정책상 비밀정보는 최초 1회만 제공됩니다.
*
- * @param requestData clientId, password 포함
+ * @param requestData clientId 포함
* @return {success, secret} / {success:false, alreadyRevealed:true} / {success:false, message}
*/
@PostMapping("/credential/reveal-secret")
@@ -331,7 +338,6 @@ public class MyAppController {
Map result = new java.util.HashMap<>();
String clientId = requestData.get("clientId");
- String password = requestData.get("password");
if (clientId == null || clientId.trim().isEmpty()) {
result.put("success", false);
@@ -341,14 +347,7 @@ public class MyAppController {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
- // 1. 본인 확인 (비밀번호)
- if (!appServiceFacade.verifyUserPassword(user, password)) {
- result.put("success", false);
- result.put("message", "비밀번호가 일치하지 않습니다.");
- return result;
- }
-
- // 2. 소유권 확인 + 1회 노출 + 물리 삭제
+ // 소유권 확인 + 1회 노출 + 물리 삭제 (본인 확인은 step-up 2FA 인터셉터가 선행)
try {
String secret = appServiceFacade.revealAndDeleteClientSecret(user.getPortalOrg().getId(), clientId);
if (secret == null) {
@@ -430,7 +429,7 @@ public class MyAppController {
@Secured("ROLE_API_KEY_REQUEST_VIEW")
public ModelAndView apiRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
if (id == null) {
- return new ModelAndView("redirect:/myapikey/api_key_request/history");
+ return new ModelAndView("redirect:/clients/api_key_request/history");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -528,7 +527,7 @@ public class MyAppController {
registration.setIpWhitelistFromString(ipWhitelist);
}
- return new ModelAndView("redirect:/myapikey/register/step2");
+ return new ModelAndView("redirect:/clients/register/step2");
} catch (Exception e) {
setupStepModel(model, 1);
@@ -593,7 +592,7 @@ public class MyAppController {
// 1단계가 완료되었는지 검증
if (!registration.isStep1Complete()) {
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
- return new ModelAndView("redirect:/myapikey/register/step1");
+ return new ModelAndView("redirect:/clients/register/step1");
}
// 서비스 카테고리만 가져오기 (API는 AJAX로 로드됨)
@@ -624,7 +623,7 @@ public class MyAppController {
}
// 1단계로 리다이렉트
- return new ModelAndView("redirect:/myapikey/register/step1");
+ return new ModelAndView("redirect:/clients/register/step1");
}
/**
@@ -642,7 +641,7 @@ public class MyAppController {
// 1단계가 완료되었는지 검증
if (!registration.isStep1Complete()) {
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
- return new ModelAndView("redirect:/myapikey/register/step1");
+ return new ModelAndView("redirect:/clients/register/step1");
}
// API 선택은 선택 사항 — 미선택(빈 목록)도 허용한다.
@@ -651,7 +650,7 @@ public class MyAppController {
// 등록이 완료되었는지 최종 검증
if (!registration.isComplete()) {
redirectAttributes.addFlashAttribute("error", "등록 정보가 완전하지 않습니다.");
- return new ModelAndView("redirect:/myapikey/register/step1");
+ return new ModelAndView("redirect:/clients/register/step1");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -668,12 +667,12 @@ public class MyAppController {
// 성공적으로 완료된 후 세션 초기화
sessionStatus.setComplete();
- return new ModelAndView("redirect:/myapikey/register/step3");
+ return new ModelAndView("redirect:/clients/register/step3");
} catch (Exception e) {
// 실패 시 에러 메시지와 함께 step2로 돌아감
redirectAttributes.addFlashAttribute("error", "API Key 등록 중 오류가 발생했습니다. 다시 시도해주세요.");
- return new ModelAndView("redirect:/myapikey/register/step2");
+ return new ModelAndView("redirect:/clients/register/step2");
}
}
@@ -691,7 +690,7 @@ public class MyAppController {
// 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우
if (registrationSuccess == null || !registrationSuccess) {
- return new ModelAndView("redirect:/myapikey");
+ return new ModelAndView("redirect:/clients");
}
// 결과 페이지 표시용 속성 설정
@@ -710,7 +709,7 @@ public class MyAppController {
public String cancelRegistration(SessionStatus sessionStatus) {
// 등록과 관련된 세션 데이터 초기화
sessionStatus.setComplete();
- return "redirect:/myapikey";
+ return "redirect:/clients";
}
/**
@@ -754,13 +753,15 @@ public class MyAppController {
@Secured("ROLE_API_KEY_REQUEST")
public ModelAndView modifyStep1(
@RequestParam(value = "clientId", required = false) String clientId,
+ @RequestParam(value = "goto", required = false) String gotoStep,
+ @RequestParam(value = "apiApplyToast", required = false) String apiApplyToast,
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
SessionStatus sessionStatus,
Model model,
RedirectAttributes redirectAttributes) {
if (clientId == null) {
- return new ModelAndView("redirect:/myapikey");
+ return new ModelAndView("redirect:/clients");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -768,7 +769,7 @@ public class MyAppController {
// 기존 API Key 정보 조회
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), clientId);
if (apiKey == null) {
- return new ModelAndView("redirect:/myapikey");
+ return new ModelAndView("redirect:/clients");
}
// 새로운 수정 세션 시작시에만 초기화
@@ -810,6 +811,16 @@ public class MyAppController {
model.addAttribute("apiKeyModification", modification);
}
+ // API 신청 절차: 클라이언트 1건 보유 시 API 상세에서 goto=apis 로 진입
+ // → 세션 초기화 후 API 선택(2단계)로 직행 (기본 정보 미완성이면 step2 가드가 1단계로 되돌림)
+ if ("apis".equals(gotoStep)) {
+ String redirectUrl = "redirect:/clients/modify/step2";
+ if (apiApplyToast != null && apiApplyToast.matches("[a-zA-Z_-]{1,30}")) {
+ redirectUrl += "?apiApplyToast=" + apiApplyToast;
+ }
+ return new ModelAndView(redirectUrl);
+ }
+
// Step 모델 설정
setupStepModel(model, 1);
model.addAttribute("userOrg", user.getPortalOrg());
@@ -861,7 +872,7 @@ public class MyAppController {
modification.setIpWhitelistFromString(ipWhitelist);
}
- return new ModelAndView("redirect:/myapikey/modify/step2");
+ return new ModelAndView("redirect:/clients/modify/step2");
} catch (Exception e) {
setupStepModel(model, 1);
@@ -883,7 +894,7 @@ public class MyAppController {
// 1단계가 완료되었는지 검증
if (!modification.isStep1Complete()) {
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
- return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
+ return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
}
// 서비스 카테고리와 API 목록 가져오기
@@ -893,6 +904,8 @@ public class MyAppController {
setupStepModel(model, 2);
model.addAttribute("apiServices", apiServices);
model.addAttribute("modification", modification);
+ // 최종 반영(저장) 직전 2FA 필요 여부 → 폼 JS 분기용
+ model.addAttribute("twofaRequired", isAppModifyTwofaRequired());
return new ModelAndView(API_KEY_MODIFY_STEP2);
}
@@ -914,7 +927,7 @@ public class MyAppController {
}
// 1단계로 리다이렉트
- return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
+ return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
}
/**
@@ -927,18 +940,19 @@ public class MyAppController {
@RequestParam(value = "selectedApis", required = false) List selectedApis,
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
SessionStatus sessionStatus,
+ HttpSession session,
RedirectAttributes redirectAttributes) {
// 1단계가 완료되었는지 검증
if (!modification.isStep1Complete()) {
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
- return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
+ return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
}
// API 선택 검증
if (selectedApis == null || selectedApis.isEmpty()) {
redirectAttributes.addFlashAttribute("error", "최소 1개 이상의 API를 선택해주세요.");
- return new ModelAndView("redirect:/myapikey/modify/step2");
+ return new ModelAndView("redirect:/clients/modify/step2");
}
// 선택된 API를 세션에 저장
@@ -947,7 +961,15 @@ public class MyAppController {
// 등록이 완료되었는지 최종 검증
if (!modification.isComplete()) {
redirectAttributes.addFlashAttribute("error", "수정 정보가 완전하지 않습니다.");
- return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
+ return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
+ }
+
+ // 반영 직전 2FA: 통과권이 없으면 커밋하지 않고 step2 로 되돌린다(프론트가 먼저 2FA 팝업을 띄운다).
+ // 진입(step1)이 아닌 최종 반영 시점에만 인증을 요구해 다단계 진행 중 중복 인증을 막는다.
+ if (isAppModifyTwofaRequired()
+ && !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.APP_MODIFY_COMMIT)) {
+ redirectAttributes.addFlashAttribute("error", "추가 인증(2FA) 후 다시 시도해 주세요.");
+ return new ModelAndView("redirect:/clients/modify/step2");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -964,12 +986,12 @@ public class MyAppController {
// 성공적으로 완료된 후 세션 초기화
sessionStatus.setComplete();
- return new ModelAndView("redirect:/myapikey/modify/step3");
+ return new ModelAndView("redirect:/clients/modify/step3");
} catch (Exception e) {
// 실패 시 에러 메시지와 함께 step2로 돌아감
redirectAttributes.addFlashAttribute("error", "API Key 수정 요청 중 오류가 발생했습니다. 다시 시도해주세요.");
- return new ModelAndView("redirect:/myapikey/modify/step2");
+ return new ModelAndView("redirect:/clients/modify/step2");
}
}
@@ -986,7 +1008,7 @@ public class MyAppController {
// 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우
if (modificationComplete == null || !modificationComplete) {
- return new ModelAndView("redirect:/myapikey");
+ return new ModelAndView("redirect:/clients");
}
// 결과 페이지 표시용 속성 설정
@@ -1015,12 +1037,18 @@ public class MyAppController {
// clientId가 있으면 상세 페이지로, 없으면 목록으로
if (clientId != null && !clientId.isEmpty()) {
- return "redirect:/myapikey/credential_detail?id=" + clientId;
+ return "redirect:/clients/credential_detail?id=" + clientId;
} else {
- return "redirect:/myapikey";
+ return "redirect:/clients";
}
}
+ /** 앱 수정 최종 반영 직전 2FA(step-up)가 현재 활성인지 — 전체/지점 스위치 AND */
+ private boolean isAppModifyTwofaRequired() {
+ return twoFactorProperties.isStepUpEnabled()
+ && twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.APP_MODIFY_COMMIT);
+ }
+
}
diff --git a/src/main/java/com/eactive/apim/portal/apps/app/service/AppServiceFacade.java b/src/main/java/com/eactive/apim/portal/apps/app/service/AppServiceFacade.java
index d841caf..dfb46fb 100644
--- a/src/main/java/com/eactive/apim/portal/apps/app/service/AppServiceFacade.java
+++ b/src/main/java/com/eactive/apim/portal/apps/app/service/AppServiceFacade.java
@@ -73,8 +73,8 @@ public class AppServiceFacade {
List appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, types,
Arrays.asList(new ProcessingState(), new RequestedState()));
- // 승인정보(approval) 없는 신청도 목록에 노출한다. (사용자가 직접 삭제 가능)
- appRequests.addAll(appRequestRepository.findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
+ // 승인정보(approval) 없는 신청도 목록에 노출`한다. (사용자가 직접 삭제 가능)
+ appRequests.addAll(appRequestRepository .findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
return appRequests;
}
diff --git a/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/StepUpProtectedPaths.java b/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/StepUpProtectedPaths.java
index 8e127a2..4919ff0 100644
--- a/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/StepUpProtectedPaths.java
+++ b/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/StepUpProtectedPaths.java
@@ -11,7 +11,8 @@ import java.util.Set;
* 검증 레벨(완화 정책)
*
* - {@link Level#TWO_FACTOR} — 공통 2FA 팝업(휴대폰/이메일 인증번호). 진입 인터셉터 또는
- * AJAX 401 신호로 유도. 예: Secret 조회/앱 해지/앱 정보수정.
+ * AJAX 401 신호로 유도. 예: Secret 조회/앱 해지. 앱 정보수정은 최종 반영(commit)
+ * 직전에 컨트롤러가 통과권을 요구한다(다단계 진행 중 중복 인증 방지).
* - {@link Level#PASSWORD} — 현재 비밀번호 재확인만 요구(2FA 없음). 별도 확인 페이지
* ({@code /auth/stepup/password})로 유도. 예: 내 정보 변경({@code /mypage}).
*
@@ -40,15 +41,17 @@ public final class StepUpProtectedPaths {
}
/** Secret 키 조회 (AJAX POST) */
- public static final String REVEAL_SECRET = "/myapikey/credential/reveal-secret";
+ public static final String REVEAL_SECRET = "/clients/credential/reveal-secret";
/** 앱 해지 신청 (AJAX POST) */
- public static final String APP_KEY_DELETE = "/myapikey/api_key_delete";
- /** 앱 정보 수정 페이지 진입 (GET) */
- public static final String APP_MODIFY_STEP1 = "/myapikey/modify/step1";
+ public static final String APP_KEY_DELETE = "/clients/api_key_delete";
+ /** 앱 정보 수정 최종 반영(commit, POST /modify/step2) — 반영 직전 2FA. 진입/중간 단계는 가드하지 않음 */
+ public static final String APP_MODIFY_COMMIT = "/clients/modify/step2";
/** 개인정보 변경 페이지 진입 (GET, 정확 일치) — PASSWORD 레벨 */
public static final String MYPAGE = "/mypage";
/** 비밀번호 변경 반영(commit, POST) — 반영 직전 2FA. 진입(GET)은 가드하지 않음 */
public static final String PASSWORD_CHANGE = "/password/change";
+ /** 회원 탈퇴 반영(commit, POST) — 반영 직전 2FA. 팝업(사유 입력) 후 프론트가 2FA 를 띄운다 */
+ public static final String WITHDRAW = "/withdraw";
/** PTL_PROPERTY 지점 키 접두 (전체 스위치 two-factor.stepup.enabled 와 구분) */
private static final String KEY_PREFIX = "two-factor.stepup.";
@@ -63,26 +66,31 @@ public final class StepUpProtectedPaths {
static {
Map keys = new LinkedHashMap<>();
keys.put(REVEAL_SECRET, KEY_PREFIX + "reveal-secret");
- keys.put(APP_MODIFY_STEP1, KEY_PREFIX + "app-modify");
+ keys.put(APP_MODIFY_COMMIT, KEY_PREFIX + "app-modify");
keys.put(APP_KEY_DELETE, KEY_PREFIX + "app-delete");
keys.put(MYPAGE, KEY_PREFIX + "mypage");
keys.put(PASSWORD_CHANGE, KEY_PREFIX + "password-change");
+ keys.put(WITHDRAW, KEY_PREFIX + "withdraw");
PATH_TO_KEY = Collections.unmodifiableMap(keys);
Map levels = new LinkedHashMap<>();
levels.put(REVEAL_SECRET, Level.TWO_FACTOR);
- levels.put(APP_MODIFY_STEP1, Level.TWO_FACTOR);
+ levels.put(APP_MODIFY_COMMIT, Level.TWO_FACTOR);
levels.put(APP_KEY_DELETE, Level.TWO_FACTOR);
levels.put(MYPAGE, Level.PASSWORD);
levels.put(PASSWORD_CHANGE, Level.TWO_FACTOR);
+ levels.put(WITHDRAW, Level.TWO_FACTOR);
PATH_TO_LEVEL = Collections.unmodifiableMap(levels);
// 인터셉터 진입 자동 차단: 2FA 레벨 중 "진입 시점" 보호가 필요한 경로만.
// - PASSWORD_CHANGE 는 반영(POST commit) 직전에 컨트롤러가 통과권을 요구 → 제외
+ // - APP_MODIFY_COMMIT 도 동일 — 다단계(step1→step2) 진행 중 중복 인증을 막기 위해
+ // 최종 반영 직전에만 컨트롤러가 통과권을 요구 → 제외
// - MYPAGE 는 별도 확인 페이지로 컨트롤러가 유도(PASSWORD 레벨) → 제외
+ // - WITHDRAW 는 팝업(사유 입력)→2FA→제출 순서로 프론트가 유도하고
+ // 컨트롤러가 커밋 직전 통과권을 요구 → 제외
Set guarded = new java.util.LinkedHashSet<>();
guarded.add(REVEAL_SECRET);
- guarded.add(APP_MODIFY_STEP1);
guarded.add(APP_KEY_DELETE);
INTERCEPTOR_GUARDED = Collections.unmodifiableSet(guarded);
}
diff --git a/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorProperties.java b/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorProperties.java
index ffbb17b..abe8ea9 100644
--- a/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorProperties.java
+++ b/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorProperties.java
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.apps.auth.twofactor;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
+import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@@ -20,6 +21,7 @@ public class TwoFactorProperties {
public static final String GROUP = "Portal";
public static final String KEY_LOGIN_ENABLED = "two-factor.login.enabled";
+ public static final String KEY_LOGIN_TARGET_ROLES = "two-factor.login.target-roles";
public static final String KEY_TTL_SECONDS = "two-factor.ttl.seconds";
public static final String KEY_ATTEMPT_LIMIT = "two-factor.attempt.limit";
public static final String KEY_TEST_NOTICE_ENABLED = "two-factor.test-notice.enabled";
@@ -32,6 +34,33 @@ public class TwoFactorProperties {
return parseBool(resolve(KEY_LOGIN_ENABLED, "false", "로그인 2차 인증 활성화 여부 (true/false)"));
}
+ /**
+ * 로그인 2FA 적용 대상 역할인지 여부.
+ * 프로퍼티 값: 쉼표 구분 RoleCode 목록(예: {@code ROLE_CORP_MANAGER,ROLE_CORP_USER})
+ * 또는 {@code ALL}(전체 대상). 기본값은 법인관리자만.
+ * 미기재 역할은 로그인 2FA 를 건너뛴다(전체 스위치 {@link #isLoginEnabled()}와 AND 동작).
+ */
+ public boolean isLoginTargetRole(RoleCode roleCode) {
+ if (roleCode == null) {
+ roleCode = RoleCode.ROLE_USER;
+ }
+ String value = resolve(KEY_LOGIN_TARGET_ROLES, RoleCode.ROLE_CORP_MANAGER.name(),
+ "로그인 2차 인증 대상 역할 (쉼표구분: ROLE_USER,ROLE_CORP_USER,ROLE_CORP_MANAGER / 전체: ALL)");
+ if (value == null || value.trim().isEmpty()) {
+ return false;
+ }
+ String trimmed = value.trim();
+ if ("ALL".equalsIgnoreCase(trimmed)) {
+ return true;
+ }
+ for (String token : trimmed.split(",")) {
+ if (roleCode.name().equalsIgnoreCase(token.trim())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/** step-up(민감기능) 2FA 전체 활성화 여부(마스터 스위치) */
public boolean isStepUpEnabled() {
return parseBool(resolve(KEY_STEPUP_ENABLED, "false", "민감기능 추가 인증(step-up) 전체 활성화 여부 (true/false)"));
diff --git a/src/main/java/com/eactive/apim/portal/apps/login/controller/DuplicateLoginController.java b/src/main/java/com/eactive/apim/portal/apps/login/controller/DuplicateLoginController.java
new file mode 100644
index 0000000..c07b708
--- /dev/null
+++ b/src/main/java/com/eactive/apim/portal/apps/login/controller/DuplicateLoginController.java
@@ -0,0 +1,51 @@
+package com.eactive.apim.portal.apps.login.controller;
+
+import com.eactive.apim.portal.apps.login.service.DuplicateLoginService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpSession;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 동시 접속(중복 세션) 확인 대기 상태(로그인 2FA off 경로)의 확정/취소 API.
+ *
+ * 대기 상태는 1차 인증(ID/PW) 성공 후 SuccessHandler 만 세팅하므로, 이 엔드포인트는
+ * 비밀번호 검증을 통과한 세션에서만 의미가 있다. 모든 POST 는 세션 기반
+ * CSRF(X-XSRF-TOKEN) 보호를 받는다.
+ */
+@RestController
+@RequestMapping("/login/duplicate")
+@RequiredArgsConstructor
+public class DuplicateLoginController {
+
+ private final DuplicateLoginService duplicateLoginService;
+
+ /** 기존 접속 해제 확인 → 로그인 확정. 무효(만료/상태 변경) 시 재로그인 안내 */
+ @PostMapping("/confirm")
+ public Map confirm(HttpServletRequest request, HttpSession session) {
+ Map result = new HashMap<>();
+ String redirect = duplicateLoginService.confirm(request, session);
+ if (redirect != null) {
+ result.put("valid", true);
+ result.put("redirect", redirect);
+ } else {
+ result.put("valid", false);
+ result.put("message", "로그인 확인이 만료되었습니다. 다시 로그인해주세요.");
+ }
+ return result;
+ }
+
+ /** 확인 취소 — 로그인 포기(익명 유지) */
+ @PostMapping("/cancel")
+ public Map cancel(HttpSession session) {
+ duplicateLoginService.cancel(session);
+ Map result = new HashMap<>();
+ result.put("valid", true);
+ return result;
+ }
+}
diff --git a/src/main/java/com/eactive/apim/portal/apps/login/controller/LoginHandler.java b/src/main/java/com/eactive/apim/portal/apps/login/controller/LoginHandler.java
index dcdcdf2..ffb2f24 100644
--- a/src/main/java/com/eactive/apim/portal/apps/login/controller/LoginHandler.java
+++ b/src/main/java/com/eactive/apim/portal/apps/login/controller/LoginHandler.java
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.apps.login.controller;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
+import com.eactive.apim.portal.apps.login.service.DuplicateLoginService;
import com.eactive.apim.portal.common.exception.PortalRedirectException;
import com.eactive.apim.portal.common.pagerouter.PageHandler;
import org.apache.commons.lang3.StringUtils;
@@ -24,9 +25,11 @@ import static com.eactive.apim.portal.apps.login.constants.LoginConstants.LOGIN_
public class LoginHandler implements PageHandler {
private final TwoFactorService twoFactorService;
+ private final DuplicateLoginService duplicateLoginService;
- public LoginHandler(TwoFactorService twoFactorService) {
+ public LoginHandler(TwoFactorService twoFactorService, DuplicateLoginService duplicateLoginService) {
this.twoFactorService = twoFactorService;
+ this.duplicateLoginService = duplicateLoginService;
}
/**
@@ -55,7 +58,24 @@ public class LoginHandler implements PageHandler {
// 로그인 2FA 대기 상태면(1차 인증 통과 후) 추가 인증 팝업 자동 오픈 플래그를 내려준다.
// pending 중에는 아직 익명이므로 아래 인증자 리다이렉트에 걸리지 않는다.
- model.addAttribute("twoFactorPending", twoFactorService.hasPendingLogin(session));
+ boolean twoFactorPending = twoFactorService.hasPendingLogin(session);
+ model.addAttribute("twoFactorPending", twoFactorPending);
+
+ // 동시 접속 안내 — 반드시 1차 인증 통과 후(2FA pending 또는 중복 확인 대기)에만 노출한다.
+ // - 2FA on: 확인 후 2FA 팝업 진행(취소 시 /auth/2fa/cancel)
+ // - 2FA off: 확인 후 /login/duplicate/confirm 으로 확정
+ String pendingLoginId = null;
+ boolean duplicateConfirmPending = false;
+ if (twoFactorPending) {
+ pendingLoginId = (String) session.getAttribute(TwoFactorService.ATTR_PENDING_LOGIN_ID);
+ } else if (duplicateLoginService.hasPending(session)
+ && "1".equals(httpRequest.getParameter("duplicate"))) {
+ pendingLoginId = duplicateLoginService.pendingLoginId(session);
+ duplicateConfirmPending = true;
+ }
+ model.addAttribute("duplicateConfirmPending", duplicateConfirmPending);
+ model.addAttribute("duplicateInfo",
+ pendingLoginId != null ? duplicateLoginService.activeSessionInfo(pendingLoginId) : null);
// 이미 인증된 사용자인지 확인
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
diff --git a/src/main/java/com/eactive/apim/portal/apps/login/service/DuplicateLoginService.java b/src/main/java/com/eactive/apim/portal/apps/login/service/DuplicateLoginService.java
new file mode 100644
index 0000000..9a03356
--- /dev/null
+++ b/src/main/java/com/eactive/apim/portal/apps/login/service/DuplicateLoginService.java
@@ -0,0 +1,185 @@
+package com.eactive.apim.portal.apps.login.service;
+
+import com.eactive.apim.portal.apps.login.constants.LoginType;
+import com.eactive.apim.portal.apps.session.entity.UserSession;
+import com.eactive.apim.portal.apps.session.service.UserSessionService;
+import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
+import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
+import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
+import com.eactive.apim.portal.portaluser.entity.PortalUser;
+import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
+import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpSession;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * 로그인 시 동시 접속(중복 세션) 확인 처리.
+ *
+ * 중복 확인은 반드시 1차 인증(ID/PW) 성공 후에만 수행한다. 비밀번호 검증 전에
+ * 노출하면 임의 계정의 접속 여부·IP 가 인증 없이 조회되는 정보 노출이 된다(기존
+ * {@code /api/session/check-duplicate} 사전 체크 방식의 문제).
+ *
+ * 두 경로에서 쓰인다:
+ *
+ * - 로그인 2FA on — 2FA pending 상태의 로그인 페이지가 {@link #activeSessionInfo(String)}
+ * 로 안내 정보를 내려주고, 확인 후 2FA 팝업으로 진행(취소 시 {@code /auth/2fa/cancel}).
+ * - 로그인 2FA off — SuccessHandler 가 확정을 보류하고 {@link #begin} 으로 대기 상태 전환.
+ * 사용자가 확인하면 {@link #confirm} 이 인증을 확정한다(기존 세션은
+ * {@link LoginFinalizer#finalizeLogin} 의 forceLogoutOtherSessions 로 해제).
+ *
+ */
+@Service
+@Transactional
+@RequiredArgsConstructor
+public class DuplicateLoginService {
+
+ /** 동시 접속 확인 대기 - 대상 사용자 id (2FA off 경로) */
+ public static final String ATTR_PENDING_USER_ID = "DUP_PENDING_USER_ID";
+ /** 동시 접속 확인 대기 - loginId */
+ public static final String ATTR_PENDING_LOGIN_ID = "DUP_PENDING_LOGIN_ID";
+ /** 동시 접속 확인 대기 - 진입 시각 */
+ public static final String ATTR_PENDING_AT = "DUP_PENDING_AT";
+
+ /** 확인 대기 유효시간(초). 초과 시 처음부터 재로그인 */
+ public static final int PENDING_TTL_SECONDS = 120;
+
+ private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ private final UserSessionService userSessionService;
+ private final PortalUserRepository portalUserRepository;
+ private final PortalUserAuthService portalUserAuthService;
+ private final LoginFinalizer loginFinalizer;
+
+ /** 해당 계정의 활성 세션(다른 곳 접속) 존재 여부 */
+ @Transactional(readOnly = true)
+ public boolean hasActiveSession(String loginId) {
+ return activeSession(loginId).isPresent();
+ }
+
+ /**
+ * 활성 세션 안내 정보(마스킹 IP·접속 시각). 없으면 null.
+ * 로그인 페이지 확인 팝업 표시용 — 1차 인증 통과 후에만 호출해야 한다.
+ */
+ @Transactional(readOnly = true)
+ public Map activeSessionInfo(String loginId) {
+ Optional active = activeSession(loginId);
+ if (!active.isPresent()) {
+ return null;
+ }
+ Map info = new HashMap<>();
+ info.put("ipAddress", maskIpAddress(active.get().getIpAddress()));
+ info.put("loginTime", active.get().getLoginTime().format(TIME_FORMATTER));
+ return info;
+ }
+
+ // =========================================================================
+ // 2FA off 경로: 확정 보류 → 확인 → 확정
+ // =========================================================================
+
+ /** 1차 인증 성공 사용자를 동시 접속 확인 대기 상태로 세팅한다. (SecurityContext 클리어는 호출부 책임) */
+ public void begin(HttpSession session, PortalUser user) {
+ session.setAttribute(ATTR_PENDING_USER_ID, user.getId());
+ session.setAttribute(ATTR_PENDING_LOGIN_ID, user.getLoginId());
+ session.setAttribute(ATTR_PENDING_AT, LocalDateTime.now());
+ }
+
+ public boolean hasPending(HttpSession session) {
+ return session != null && session.getAttribute(ATTR_PENDING_USER_ID) != null;
+ }
+
+ public String pendingLoginId(HttpSession session) {
+ return session == null ? null : (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
+ }
+
+ /**
+ * 동시 접속 확인 후 로그인 확정. 대기 상태가 유효하면 인증을 세팅하고 최종 이동 URL 을
+ * 반환한다(기존 세션 해제 포함). 무효(만료/상태 변경)면 null — 재로그인 필요.
+ */
+ public String confirm(HttpServletRequest request, HttpSession session) {
+ String userId = (String) session.getAttribute(ATTR_PENDING_USER_ID);
+ String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
+ Object at = session.getAttribute(ATTR_PENDING_AT);
+ cancel(session); // 1회용 — 성공/실패 무관하게 대기 상태는 소멸
+
+ if (userId == null || !(at instanceof LocalDateTime)
+ || ((LocalDateTime) at).plusSeconds(PENDING_TTL_SECONDS).isBefore(LocalDateTime.now())) {
+ return null;
+ }
+
+ PortalUser user = portalUserRepository.findById(userId).orElse(null);
+ if (user == null || !isLoginStillAllowed(user)) {
+ return null;
+ }
+
+ // 프로그래매틱 인증 확정 (요청 종료 시 SecurityContextPersistenceFilter 가 세션에 저장)
+ PortalAuthenticatedUser authUser = portalUserAuthService.buildAuthenticatedUser(user);
+ UsernamePasswordAuthenticationToken token =
+ new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());
+ token.setDetails(authUser);
+ SecurityContextHolder.getContext().setAuthentication(token);
+
+ return loginFinalizer.finalizeLogin(user, loginId, request, LoginType.NORMAL);
+ }
+
+ /** 확인 취소 — 대기 상태 정리(익명 유지) */
+ public void cancel(HttpSession session) {
+ session.removeAttribute(ATTR_PENDING_USER_ID);
+ session.removeAttribute(ATTR_PENDING_LOGIN_ID);
+ session.removeAttribute(ATTR_PENDING_AT);
+ }
+
+ // =========================================================================
+ // 내부 helper
+ // =========================================================================
+
+ private Optional activeSession(String loginId) {
+ if (loginId == null) {
+ return Optional.empty();
+ }
+ return userSessionService.getActiveSession(loginId.toLowerCase());
+ }
+
+ /** 1차 인증~확인 사이 계정 상태 변경 방어 (TwoFactorService.revalidateLoginState 와 동일 기준) */
+ private boolean isLoginStillAllowed(PortalUser user) {
+ if ("Y".equalsIgnoreCase(user.getAccountLockYn())) {
+ return false;
+ }
+ if (PortalUserEnums.UserStatus.ADMINBLOCK.equals(user.getUserStatus())) {
+ return false;
+ }
+ if (PortalUserEnums.ApprovalStatus.PENDING.equals(user.getApprovalStatus())) {
+ return false;
+ }
+ return user.getPortalOrg() == null
+ || PortalOrgEnums.ApprovalStatus.COMPLETED.equals(user.getPortalOrg().getApprovalStatus());
+ }
+
+ /**
+ * IP 주소 마스킹 (3번째 옥텟을 ***로 치환). 예: 192.168.240.178 → 192.168.***.178
+ */
+ private static String maskIpAddress(String ip) {
+ if (ip == null || ip.isEmpty()) {
+ return "알 수 없음";
+ }
+ String[] parts = ip.split("\\.");
+ if (parts.length == 4) {
+ return parts[0] + "." + parts[1] + ".***." + parts[3];
+ }
+ // IPv6 등 다른 형식은 일부만 표시
+ if (ip.length() > 8) {
+ return ip.substring(0, 4) + "****" + ip.substring(ip.length() - 4);
+ }
+ return "***";
+ }
+}
diff --git a/src/main/java/com/eactive/apim/portal/apps/login/service/LoginFinalizer.java b/src/main/java/com/eactive/apim/portal/apps/login/service/LoginFinalizer.java
index 63b0591..b6d3978 100644
--- a/src/main/java/com/eactive/apim/portal/apps/login/service/LoginFinalizer.java
+++ b/src/main/java/com/eactive/apim/portal/apps/login/service/LoginFinalizer.java
@@ -118,6 +118,14 @@ public class LoginFinalizer {
}
}
+ // 법인 사용자(관리자/개발자) 로그인 시, 홈 최초 진입에서 클라이언트 신규 신청 유도 팝업을 1회 노출하도록 마킹한다.
+ // 실제 클라이언트(승인+요청중) 보유 여부 판정과 1회 소비는 IndexController 가 담당한다.
+ PortalUserEnums.RoleCode roleCode = user.getRoleCode();
+ if (roleCode == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER
+ || roleCode == PortalUserEnums.RoleCode.ROLE_CORP_USER) {
+ session.setAttribute("checkClientRegister", true);
+ }
+
// 중복 로그인 방지: 기존 세션 강제 로그아웃 + 현재 세션 등록
String clientIp = HttpRequestUtil.getClientIpAddress(request);
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
diff --git a/src/main/java/com/eactive/apim/portal/apps/main/controller/IndexController.java b/src/main/java/com/eactive/apim/portal/apps/main/controller/IndexController.java
index 1f6716b..e32e0d4 100644
--- a/src/main/java/com/eactive/apim/portal/apps/main/controller/IndexController.java
+++ b/src/main/java/com/eactive/apim/portal/apps/main/controller/IndexController.java
@@ -1,10 +1,14 @@
package com.eactive.apim.portal.apps.main.controller;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
+import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
import com.eactive.apim.portal.apps.main.dto.IndexStatisticsDTO;
import com.eactive.apim.portal.apps.main.service.IndexStatisticsService;
import com.eactive.apim.portal.apps.main.service.MainApiFacade;
+import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
+import com.eactive.apim.portal.common.util.SecurityUtil;
import java.util.List;
+import javax.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -17,6 +21,7 @@ public class IndexController {
private final MainApiFacade mainApiFacade;
private final IndexStatisticsService indexStatisticsService;
+ private final AppServiceFacade appServiceFacade;
/**
* 메인 페이지 API는 Portal Property 에 main.service.list 에 등록된 그룹을 기준으로 API를 조회함.
@@ -29,7 +34,7 @@ public class IndexController {
* @return
*/
@GetMapping("/")
- public String index(Model model) {
+ public String index(Model model, HttpSession session) {
List services = mainApiFacade.getOpenApiServices();
List hashTags = mainApiFacade.getHashTags();
@@ -39,9 +44,37 @@ public class IndexController {
addAttributesToModel(model, services, hashTags, statistics);
+ resolveClientRegisterNudge(model, session);
+
return "apps/main/index";
}
+ /**
+ * 법인 사용자(관리자/개발자) 로그인 직후, 클라이언트(승인+요청중)가 하나도 없으면
+ * 클라이언트 신규 신청 유도 팝업 노출 플래그를 세팅한다.
+ *
+ * 노출 시점은 {@code LoginFinalizer} 가 로그인 시 세팅한 {@code checkClientRegister}
+ * 세션 마커로 통제한다. 마커는 홈 최초 진입에서 1회 소비하여, 이후 홈 재방문 시
+ * 반복 노출되지 않게 한다. (역할 게이팅은 마커 세팅 측에서 이미 수행됨)
+ */
+ private void resolveClientRegisterNudge(Model model, HttpSession session) {
+ if (session.getAttribute("checkClientRegister") == null) {
+ return;
+ }
+ session.removeAttribute("checkClientRegister"); // 1회 소비
+
+ PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
+ if (user == null || user.getPortalOrg() == null) {
+ return;
+ }
+
+ boolean hasClient = !appServiceFacade.getApikeyList(user.getPortalOrg()).isEmpty()
+ || !appServiceFacade.getPendingApiKeyList(user.getPortalOrg()).isEmpty();
+ if (!hasClient) {
+ model.addAttribute("needClientRegister", true);
+ }
+ }
+
private void addAttributesToModel(Model model, List apiServices, List hashTags, IndexStatisticsDTO statistics) {
model.addAttribute("services", apiServices);
diff --git a/src/main/java/com/eactive/apim/portal/apps/session/controller/SessionApiController.java b/src/main/java/com/eactive/apim/portal/apps/session/controller/SessionApiController.java
index c83b133..cee15b8 100644
--- a/src/main/java/com/eactive/apim/portal/apps/session/controller/SessionApiController.java
+++ b/src/main/java/com/eactive/apim/portal/apps/session/controller/SessionApiController.java
@@ -1,6 +1,5 @@
package com.eactive.apim.portal.apps.session.controller;
-import com.eactive.apim.portal.apps.session.entity.UserSession;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -9,23 +8,23 @@ 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;
-import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
-import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
-import java.util.Optional;
/**
- * 세션 타이머/유휴 로그아웃/중복로그인 처리용 REST API.
+ * 세션 타이머/유휴 로그아웃 처리용 REST API.
+ *
+ * 중복 세션 확인은 비밀번호 검증 전 정보 노출 문제로 로그인 전 사전 체크
+ * ({@code /api/session/check-duplicate})를 제거하고, 1차 인증 통과 후
+ * {@code DuplicateLoginService} 가 처리한다.
*
*
* - GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)
* - POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)
- * - POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)
* - GET /api/session/ping - 익명 세션 keepalive (로그인/회원가입 페이지)
* - GET /api/session/csrf - 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망)
*
@@ -36,32 +35,8 @@ import java.util.Optional;
@RequiredArgsConstructor
public class SessionApiController {
- private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
-
private final UserSessionService userSessionService;
- /**
- * 로그인 전 중복 세션 확인
- */
- @PostMapping("/check-duplicate")
- public ResponseEntity