diff --git a/build.gradle b/build.gradle
index ff330c4..90b7ea6 100644
--- a/build.gradle
+++ b/build.gradle
@@ -145,6 +145,14 @@ sourceSets {
configurations {
annotationProcessor
+
+ // WebLogic 배포 시 Tyrus WebSocket 필터(weblogic.websocket.tyrus.TyrusServletFilter)와
+ // 충돌 방지: WAR 에 번들된 Tomcat WsSci 가 javax.websocket.server.ServerContainer 속성을
+ // WsServerContainer 로 등록 → WebLogic Tyrus 필터가 TyrusServerContainer 로 캐스팅하다 실패.
+ // 앱은 WebSocket 미사용이므로 Tomcat WebSocket 모듈 제외.
+ all {
+ exclude group: 'org.apache.tomcat.embed', module: 'tomcat-embed-websocket'
+ }
}
compileJava {
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..3ae473c 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;
@@ -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
@@ -893,6 +898,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);
}
@@ -927,6 +934,7 @@ public class MyAppController {
@RequestParam(value = "selectedApis", required = false) List selectedApis,
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
SessionStatus sessionStatus,
+ HttpSession session,
RedirectAttributes redirectAttributes) {
// 1단계가 완료되었는지 검증
@@ -950,6 +958,14 @@ public class MyAppController {
return new ModelAndView("redirect:/myapikey/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:/myapikey/modify/step2");
+ }
+
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
try {
@@ -1021,6 +1037,12 @@ public class MyAppController {
}
}
+ /** 앱 수정 최종 반영 직전 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/auth/AuthNoticeProperties.java b/src/main/java/com/eactive/apim/portal/apps/auth/AuthNoticeProperties.java
new file mode 100644
index 0000000..44d2c26
--- /dev/null
+++ b/src/main/java/com/eactive/apim/portal/apps/auth/AuthNoticeProperties.java
@@ -0,0 +1,43 @@
+package com.eactive.apim.portal.apps.auth;
+
+import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.core.env.Environment;
+import org.springframework.core.env.Profiles;
+import org.springframework.stereotype.Component;
+
+/**
+ * 인증(이메일/SMS) 테스트 안내 관련 PTL_PROPERTY 접근 래퍼.
+ *
+ *
그룹 {@code Portal}, 키 {@code auth.test-notice.enabled}(true/false). 값이 참이면 인증 요청
+ * 응답에 인증번호를 실어 화면에 노출한다(실제 발송 대신 테스트 확인 용도). 기존 application.yml
+ * {@code portal.test-auth-notice-enabled} 설정을 DB PTL_PROPERTY 로 이전한 것으로,
+ * {@link TwoFactorProperties} 와 동일한 {@code getOrCreateProperty} 패턴을 따른다.
+ *
+ *
prod 프로파일에서는 DB 값과 무관하게 항상 false 를 반환한다(운영 환경 인증번호 노출 금지).
+ * 세션 keepalive 등 다른 비운영 전용 스위치와 동일한 정책이다.
+ */
+@Component
+@RequiredArgsConstructor
+public class AuthNoticeProperties {
+
+ public static final String GROUP = "Portal";
+ public static final String KEY_TEST_NOTICE_ENABLED = "auth.test-notice.enabled";
+
+ private final PortalPropertyService portalPropertyService;
+ private final Environment environment;
+
+ /**
+ * 인증 요청 응답에 인증번호를 실어 UI 에 노출할지 여부(개발/테스트 전용).
+ * prod 환경에서는 property 값과 무관하게 항상 false.
+ */
+ public boolean isTestNoticeEnabled() {
+ if (environment.acceptsProfiles(Profiles.of("prod"))) {
+ return false;
+ }
+ String value = portalPropertyService.getOrCreateProperty(
+ GROUP, KEY_TEST_NOTICE_ENABLED, "true",
+ "인증(이메일/SMS) 요청 시 인증번호를 화면에 표시할지 여부 (true/false, 테스트 전용)");
+ return value != null && "true".equalsIgnoreCase(value.trim());
+ }
+}
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..e57d573 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;
*
+ * AJAX 401 신호로 유도. 예: Secret 조회/앱 해지. 앱 정보수정은 최종 반영(commit)
+ * 직전에 컨트롤러가 통과권을 요구한다(다단계 진행 중 중복 인증 방지).
*
{@link Level#PASSWORD} — 현재 비밀번호 재확인만 요구(2FA 없음). 별도 확인 페이지
* ({@code /auth/stepup/password})로 유도. 예: 내 정보 변경({@code /mypage}).
*
@@ -43,12 +44,14 @@ public final class StepUpProtectedPaths {
public static final String REVEAL_SECRET = "/myapikey/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";
+ /** 앱 정보 수정 최종 반영(commit, POST /modify/step2) — 반영 직전 2FA. 진입/중간 단계는 가드하지 않음 */
+ public static final String APP_MODIFY_COMMIT = "/myapikey/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/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/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