merge후 충돌 해결

This commit is contained in:
hong
2026-07-28 18:18:23 +09:00
42 changed files with 807 additions and 209 deletions
+8
View File
@@ -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 {
@@ -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<String> 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);
}
}
@@ -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 접근 래퍼.
*
* <p>그룹 {@code Portal}, 키 {@code auth.test-notice.enabled}(true/false). 값이 참이면 인증 요청
* 응답에 인증번호를 실어 화면에 노출한다(실제 발송 대신 테스트 확인 용도). 기존 application.yml
* {@code portal.test-auth-notice-enabled} 설정을 DB PTL_PROPERTY 로 이전한 것으로,
* {@link TwoFactorProperties} 와 동일한 {@code getOrCreateProperty} 패턴을 따른다.</p>
*
* <p><b>prod 프로파일에서는 DB 값과 무관하게 항상 false</b> 를 반환한다(운영 환경 인증번호 노출 금지).
* 세션 keepalive 등 다른 비운영 전용 스위치와 동일한 정책이다.</p>
*/
@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());
}
}
@@ -11,7 +11,8 @@ import java.util.Set;
* <p>검증 레벨(완화 정책)</p>
* <ul>
* <li>{@link Level#TWO_FACTOR} — 공통 2FA 팝업(휴대폰/이메일 인증번호). 진입 인터셉터 또는
* AJAX 401 신호로 유도. 예: Secret 조회/앱 해지/앱 정보수정.</li>
* AJAX 401 신호로 유도. 예: Secret 조회/앱 해지. 앱 정보수정은 최종 반영(commit)
* 직전에 컨트롤러가 통과권을 요구한다(다단계 진행 중 중복 인증 방지).</li>
* <li>{@link Level#PASSWORD} — 현재 비밀번호 재확인만 요구(2FA 없음). 별도 확인 페이지
* ({@code /auth/stepup/password})로 유도. 예: 내 정보 변경({@code /mypage}).</li>
* </ul>
@@ -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<String, String> 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<String, Level> 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<String> guarded = new java.util.LinkedHashSet<>();
guarded.add(REVEAL_SECRET);
guarded.add(APP_MODIFY_STEP1);
guarded.add(APP_KEY_DELETE);
INTERCEPTOR_GUARDED = Collections.unmodifiableSet(guarded);
}
@@ -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.
*
* <p>대기 상태는 1차 인증(ID/PW) 성공 후 SuccessHandler 만 세팅하므로, 이 엔드포인트는
* 비밀번호 검증을 통과한 세션에서만 의미가 있다. 모든 POST 는 세션 기반
* CSRF(X-XSRF-TOKEN) 보호를 받는다.</p>
*/
@RestController
@RequestMapping("/login/duplicate")
@RequiredArgsConstructor
public class DuplicateLoginController {
private final DuplicateLoginService duplicateLoginService;
/** 기존 접속 해제 확인 → 로그인 확정. 무효(만료/상태 변경) 시 재로그인 안내 */
@PostMapping("/confirm")
public Map<String, Object> confirm(HttpServletRequest request, HttpSession session) {
Map<String, Object> 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<String, Object> cancel(HttpSession session) {
duplicateLoginService.cancel(session);
Map<String, Object> result = new HashMap<>();
result.put("valid", true);
return result;
}
}
@@ -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();
@@ -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;
/**
* 로그인 시 동시 접속(중복 세션) 확인 처리.
*
* <p>중복 확인은 반드시 <b>1차 인증(ID/PW) 성공 후</b>에만 수행한다. 비밀번호 검증 전에
* 노출하면 임의 계정의 접속 여부·IP 가 인증 없이 조회되는 정보 노출이 된다(기존
* {@code /api/session/check-duplicate} 사전 체크 방식의 문제).</p>
*
* <p>두 경로에서 쓰인다:
* <ul>
* <li>로그인 2FA on — 2FA pending 상태의 로그인 페이지가 {@link #activeSessionInfo(String)}
* 로 안내 정보를 내려주고, 확인 후 2FA 팝업으로 진행(취소 시 {@code /auth/2fa/cancel}).</li>
* <li>로그인 2FA off — SuccessHandler 가 확정을 보류하고 {@link #begin} 으로 대기 상태 전환.
* 사용자가 확인하면 {@link #confirm} 이 인증을 확정한다(기존 세션은
* {@link LoginFinalizer#finalizeLogin} 의 forceLogoutOtherSessions 로 해제).</li>
* </ul></p>
*/
@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<String, String> activeSessionInfo(String loginId) {
Optional<UserSession> active = activeSession(loginId);
if (!active.isPresent()) {
return null;
}
Map<String, String> 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<UserSession> 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 "***";
}
}
@@ -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.
*
* <p>중복 세션 확인은 비밀번호 검증 전 정보 노출 문제로 로그인 전 사전 체크
* ({@code /api/session/check-duplicate})를 제거하고, 1차 인증 통과 후
* {@code DuplicateLoginService} 가 처리한다.</p>
*
* <ul>
* <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>
@@ -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<Map<String, Object>> checkDuplicate(@RequestParam("loginId") String loginId) {
Map<String, Object> result = new HashMap<>();
String normalizedLoginId = loginId != null ? loginId.toLowerCase() : "";
Optional<UserSession> activeSession = userSessionService.getActiveSession(normalizedLoginId);
if (activeSession.isPresent()) {
UserSession session = activeSession.get();
result.put("duplicateSession", true);
result.put("ipAddress", maskIpAddress(session.getIpAddress()));
result.put("loginTime", session.getLoginTime().format(TIME_FORMATTER));
} else {
result.put("duplicateSession", false);
}
return ResponseEntity.ok(result);
}
/**
* 세션 상태 폴링 (인증 필요)
*/
@@ -142,23 +117,4 @@ public class SessionApiController {
}
return ResponseEntity.ok(result);
}
/**
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
* 예: 192.168.240.178 → 192.168.***.178
*/
private 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 "***";
}
}
@@ -136,7 +136,8 @@ public class AccountController {
new SecurityContextLogoutHandler().logout(request, response,
SecurityContextHolder.getContext().getAuthentication());
redirectAttributes.addFlashAttribute("success", "비밀번호가 성공적으로 변경되었습니다.");
redirectAttributes.addFlashAttribute("success",
"비밀번호가 성공적으로 변경되었습니다.<br>새 비밀번호로 다시 로그인해주세요.");
return "redirect:/login";
} catch (IllegalArgumentException e) {
// 검증 실패(비밀번호 규칙/이력 등) — 사용자에게 안내, 스택은 불필요
@@ -182,6 +183,9 @@ public class AccountController {
// 기존 user 객체도 유지 (다른 곳에서 필요할 수 있으므로)
mav.addObject("user", user);
// 회원 탈퇴 팝업: 제출 전에 2FA 팝업을 띄울지 여부
mav.addObject("withdrawTwofaRequired", isWithdrawTwofaRequired());
// ROLE_USER인 경우 초대 여부 확인
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
java.util.Optional<UserInvitation> pendingInvitation =
@@ -401,21 +405,35 @@ public class AccountController {
@PostMapping("/withdraw")
public String processWithdrawal(
@RequestParam(value = "withdrawReason", required = false) String withdrawReason,
HttpSession session,
RedirectAttributes redirectAttributes) {
try {
// 탈퇴 사유 필수
if (withdrawReason == null || withdrawReason.trim().isEmpty()) {
redirectAttributes.addFlashAttribute("error", "탈퇴 사유를 입력해 주세요.");
return "redirect:/mypage";
}
// 반영 직전 2FA: 통과권이 없으면 커밋하지 않는다(프론트가 먼저 2FA 팝업을 띄운다).
if (isWithdrawTwofaRequired()
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.WITHDRAW)) {
redirectAttributes.addFlashAttribute("error", "추가 인증(2FA) 후 다시 시도해 주세요.");
return "redirect:/mypage";
}
// 현재 로그인한 사용자 정보 가져오기
PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser();
// 회원 탈퇴 처리
if (currentUser != null) {
userFacade.withdrawUser(currentUser.getId());
userFacade.withdrawUser(currentUser.getId(), withdrawReason.trim());
}
session.invalidate();
SecurityContextHolder.clearContext();
redirectAttributes.addFlashAttribute("success", "회원 탈퇴 신청이 완료 되었습니다. API Portal 회원 정보가 완전히 삭제 니다.");
redirectAttributes.addFlashAttribute("success", "회원 탈퇴 완료 되었습니다. API Portal 회원 정보가 완전히 삭제 되었습니다.");
return "redirect:/";
} catch (IllegalArgumentException e) {
redirectAttributes.addFlashAttribute("error", e.getMessage());
@@ -423,6 +441,12 @@ public class AccountController {
}
}
/** 회원 탈퇴({@code /withdraw}) 반영 직전 2FA 를 요구할지 여부 */
private boolean isWithdrawTwofaRequired() {
return twoFactorProperties.isStepUpEnabled()
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.WITHDRAW);
}
@GetMapping("/mypage/verification-email")
public String showVerificationEmailPage(Model model) {
try {
@@ -11,6 +11,8 @@ import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpSession;
@Controller
@RequestMapping("/signup/portalOrg")
@RequiredArgsConstructor
@@ -39,17 +41,28 @@ public class OrgRegisterController {
@RequestParam("registrationScenario") String scenario,
@ModelAttribute PortalOrgRegistrationDTO orgDTO,
@ModelAttribute UserAgreementDTO agreementDTO,
HttpSession session,
Model model) {
try {
ValidationResponse response;
switch (scenario) {
case "new":
case "new": {
// 가입 폼에서 이메일 인증을 마쳤는지 세션으로 검증 (개인 경로와 동일, 클라 hidden 불신)
String verifiedEmail = (String) session.getAttribute("signupVerifiedEmail");
boolean emailVerified = verifiedEmail != null
&& verifiedEmail.equalsIgnoreCase(orgDTO.getLoginId());
response = orgRegisterFacade.registerNewOrgUser(
orgDTO,
agreementDTO);
agreementDTO,
emailVerified);
if (response.isValid() && emailVerified) {
session.removeAttribute("signupVerifiedEmail");
session.removeAttribute("signupEmailPending");
}
break;
}
case "retain":
response = orgRegisterFacade.convertToOrgUser(
@@ -146,12 +146,13 @@ public class UserRegisterController {
if (invitationToken == null) {
Optional<PortalUser> registered = portalUserService.findByLoginId(portalUserRegistrationDTO.getLoginId());
// 이메일 인증을 마쳐 ACTIVE 로 저장된 경우 → 바로 자동 로그인(2차 인증 없이) 후 메인 이동
// 이메일 인증을 마쳐 ACTIVE 로 저장된 경우 → 자동 로그인(2차 인증 없이) 후 가입 완료 페이지 노출
// (완료 페이지의 '홈으로' 버튼으로 로그인 상태 그대로 메인 이동)
if (registered.isPresent()
&& PortalUserEnums.UserStatus.ACTIVE.equals(registered.get().getUserStatus())) {
portalUserAuthService.autoLoginAfterSignup(registered.get(), request);
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
return "redirect:/";
return "redirect:/signup/complete";
}
// 이메일 미인증(READY) → 회원가입 직후 이메일 인증 단계로 이동(기존 흐름 유지)
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.apps.user.facade;
import com.eactive.apim.portal.apps.auth.AuthNoticeProperties;
import com.eactive.apim.portal.apps.auth.service.AuthNumberService;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
import lombok.RequiredArgsConstructor;
@@ -13,6 +14,7 @@ import org.slf4j.LoggerFactory;
public class AuthFacadeImpl implements AuthFacade {
private final AuthNumberService authNumberService;
private final AuthNoticeProperties authNoticeProperties;
private static final Logger log = LoggerFactory.getLogger(AuthFacadeImpl.class);
@@ -44,7 +46,10 @@ public class AuthFacadeImpl implements AuthFacade {
String generatedAuthNumber = authNumberService.sendRequestAuthNumber(recipientKey, msgType);
response.setValid(true);
response.setMessage("인증번호를 발송하였습니다.");
response.setAuthNumber(generatedAuthNumber); // 테스트 환경에서 인증번호 표시용
// 테스트 환경(PTL_PROPERTY auth.test-notice.enabled=true, prod 제외)에서 인증번호를 응답에 노출
if (authNoticeProperties.isTestNoticeEnabled()) {
response.setAuthNumber(generatedAuthNumber);
}
} catch (Exception e) {
response.setValid(false);
response.setMessage(e.getMessage());
@@ -7,10 +7,11 @@ import org.springframework.http.ResponseEntity;
public interface OrgRegisterFacade {
// 신규 법인 회원 등록
// 신규 법인 회원 등록 (emailVerified: 가입 폼에서 이메일 인증 완료 시 true → 바로 ACTIVE)
ValidationResponse registerNewOrgUser(
PortalOrgRegistrationDTO orgDTO,
UserAgreementDTO agreementDTO) ;
UserAgreementDTO agreementDTO,
boolean emailVerified) ;
// 기존 회원의 법인 전환
ValidationResponse convertToOrgUser(
@@ -64,7 +64,8 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
@Transactional
public ValidationResponse registerNewOrgUser(
PortalOrgRegistrationDTO orgDTO,
UserAgreementDTO agreementDTO) {
UserAgreementDTO agreementDTO,
boolean emailVerified) {
if (!agreementValidator.isAgreementAccepted(agreementDTO)) {
return new ValidationResponse(false, "약관에 모두 동의해야 합니다.");
@@ -87,7 +88,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
if (uploadedFile == null) {
return new ValidationResponse(false, "첨부파일이 올바르지 않습니다.");
}
return registerNewCorporateUser(orgDTO, uploadedFile);
return registerNewCorporateUser(orgDTO, uploadedFile, emailVerified);
} catch (IllegalArgumentException | IOException e) {
return new ValidationResponse(false, e.getMessage());
}
@@ -179,7 +180,8 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
// 신규 법인 사용자 등록 메서드
private ValidationResponse registerNewCorporateUser(
PortalOrgRegistrationDTO orgDTO,
FileInfo uploadedFile) {
FileInfo uploadedFile,
boolean emailVerified) {
// 사업자등록번호 중복 체크 (이중 방어)
if (portalOrgService.existsByCompRegNo(orgDTO.getCompRegNo())) {
@@ -190,7 +192,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
PortalOrg newOrg = portalOrgService.registerOrgFromDTOWithFile(orgDTO, uploadedFile);
// 사용자 생성 및 기관 연결
PortalUser newUser = portalUserService.createUserWithOrg(orgDTO, newOrg, "corporate");
PortalUser newUser = portalUserService.createUserWithOrg(orgDTO, newOrg, "corporate", emailVerified);
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
@@ -14,7 +14,7 @@ public interface UserFacade {
void updateCorporateManager(PortalUserDTO portalUserDTO);
void withdrawUser(String userId);
void withdrawUser(String userId, String withdrawalReason);
void activateUserByEmail(String email);
}
@@ -131,7 +131,7 @@ public class UserFacadeImpl implements UserFacade {
}
@Override
public void withdrawUser(String userId) {
public void withdrawUser(String userId, String withdrawalReason) {
PortalUser user = portalUserService.findById(userId);
// 법인 관리자 탈퇴 제한
@@ -147,7 +147,7 @@ public class UserFacadeImpl implements UserFacade {
// 메시지 요청정보 삭제
messageRequestFacade.deleteUserMessage(user.getUserName(),user.getLoginId());
portalUserService.deleteUser(user);
portalUserService.deleteUser(user, withdrawalReason);
log.info("회원 탈퇴 처리 완료: {}", StringMaskingUtil.maskLoginId(user.getLoginId()));
}
@@ -9,7 +9,6 @@ import com.eactive.apim.portal.common.exception.UserNotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.EncryptionUtil;
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.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
@@ -136,12 +135,9 @@ public class PortalUserAuthService implements UserDetailsService {
throw new UserNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
}
return users.stream().map(user -> {
PortalUserDTO dto = portalUserMapper.toDTO(user);
dto.setMaskedEmailAddr(StringMaskingUtil.maskEmail(dto.getLoginId()));
dto.setLoginId(null);
return dto;
}).collect(Collectors.toList());
// 아이디 찾기: 이름+휴대폰 본인인증을 마친 사용자에게 보여주는 결과이므로
// 이메일(아이디)을 마스킹 없이 그대로 반환한다.
return users.stream().map(portalUserMapper::toDTO).collect(Collectors.toList());
} catch (UserNotFoundException e) {
throw e;
@@ -164,6 +160,9 @@ public class PortalUserAuthService implements UserDetailsService {
String tempPassword = EncryptionUtil.generateNewPassword();
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
// 임시 비밀번호 발급 → 변경일을 null 로 초기화해 로그인 시 강제 비밀번호 변경을 유도한다
// (LoginFinalizer.applyPostLoginState 의 passwordChangeDate == null 분기)
portalUser.setPasswordChangeDate(null);
if ("Y".equalsIgnoreCase(portalUser.getAccountLockYn())) {
portalUser.setAccountLockYn("N");
}
@@ -175,11 +175,26 @@ public class PortalUserService {
}
public PortalUser createUserWithOrg(PortalUserRegistrationDTO userDTO, PortalOrg org, String registrationType) {
return createUserWithOrg(userDTO, org, registrationType, false);
}
/**
* @param emailVerified 가입 폼에서 이메일 인증을 이미 완료했으면 true → 바로 ACTIVE 로 저장
* (개인 경로 registerActiveUser 와 동일. 법인 신규가입 경로에서 사용)
*/
public PortalUser createUserWithOrg(PortalUserRegistrationDTO userDTO, PortalOrg org, String registrationType, boolean emailVerified) {
PortalUser newUser = new PortalUser();
mapDtoToEntity(newUser, userDTO);
newUser.setPortalOrg(org);
setUserProperties(newUser);
newUser.setUserStatus(UserStatus.READY);
// 이메일 인증 기능 비활성화 시, 또는 가입 폼에서 이미 인증을 마친 경우 바로 활성화 처리
Map<String, String> propertyMap = portalPropertyService.getPortalPropertiesAsMap("Portal");
if (emailVerified
|| "true".equalsIgnoreCase(propertyMap.getOrDefault("disable_features.user_email_verify", ""))) {
newUser.setUserStatus(UserStatus.ACTIVE);
}
newUser.setApprovalStatus(ApprovalStatus.COMPLETED);
setUserRole(newUser, registrationType);
return portalUserRepository.save(newUser);
@@ -265,7 +280,7 @@ public class PortalUserService {
return portalUserRepository.save(user);
}
public void deleteUser(PortalUser user) {
public void deleteUser(PortalUser user, String withdrawalReason) {
LocalDateTime now = LocalDateTime.now();
String withdrawalDate = now.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"));
@@ -275,6 +290,12 @@ public class PortalUserService {
user.setMobileNumber("");
user.setPasswordHash("");
// 탈퇴 사유 보존 (컬럼 길이 200 초과분은 잘라 저장)
if (withdrawalReason != null && withdrawalReason.length() > 200) {
withdrawalReason = withdrawalReason.substring(0, 200);
}
user.setWithdrawalReason(withdrawalReason);
user.setUserStatus(PortalUserEnums.UserStatus.REMOVED);
portalUserRepository.save(user);
@@ -9,6 +9,7 @@ import org.springframework.core.env.Profiles;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.apps.auth.AuthNoticeProperties;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.common.security.ClientGuardService;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
@@ -31,6 +32,9 @@ public class GlobalControllerAdvice {
@Autowired
private PortalPropertyService portalPropertyService;
@Autowired
private AuthNoticeProperties authNoticeProperties;
@Autowired
private Environment environment;
@@ -48,12 +52,12 @@ public class GlobalControllerAdvice {
@ModelAttribute("showTestAuthNotice")
public boolean showTestAuthNotice() {
return portalProperties.isTestAuthNoticeEnabled();
return authNoticeProperties.isTestNoticeEnabled();
}
@ModelAttribute("testAuthNumber")
public String getTestAuthNumber() {
if (portalProperties.isTestAuthNoticeEnabled()) {
if (authNoticeProperties.isTestNoticeEnabled()) {
String authVirtualCode = portalProperties.getAuthVirtualCode();
// 고정 인증번호가 있으면 반환, 없으면 "random" 표시
return (authVirtualCode != null && !authVirtualCode.isEmpty()) ? authVirtualCode : "random";
@@ -79,6 +79,21 @@ public class PortalAuthenticationManager implements AuthenticationManager {
}
if (user.getPortalOrg() != null) {
// 법인은 "정상(ACTIVE)" 상태에서만 로그인 허용 (준비/탈퇴/휴면 차단, 2FA 이전 단계)
PortalOrgEnums.OrgStatus orgStatus = user.getPortalOrg().getOrgStatus();
if (!PortalOrgEnums.OrgStatus.ACTIVE.equals(orgStatus)) {
log.debug("법인 비정상 상태 로그인 차단 - user : {} / org.orgStatus : {}", user.getUsername(), orgStatus);
if (PortalOrgEnums.OrgStatus.REMOVED.equals(orgStatus)) {
throw new DisabledException("삭제된 법인의 계정입니다. 로그인할 수 없습니다.");
}
if (PortalOrgEnums.OrgStatus.INACTIVE.equals(orgStatus)) {
throw new DisabledException("휴면 상태의 법인입니다. 관리자에게 문의하세요.");
}
// READY 등 승인 전 상태
throw new DisabledException("로그인할 수 없습니다. 관리자에게 문의하세요. (법인 승인대기중)");
}
// 방어적: ACTIVE 인데 승인 미완료인 예외 케이스 차단
if (!user.getPortalOrg().getApprovalStatus().equals(PortalOrgEnums.ApprovalStatus.COMPLETED)) {
log.debug("기업사용자 - getApprovalStatus : {} / - org.approvalStatus : {}", user.getApprovalStatus(), user.getPortalOrg().getApprovalStatus());
throw new DisabledException("로그인할 수 없습니다. 관리자에게 문의하세요. (법인 승인대기중)");
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
import com.eactive.apim.portal.apps.login.constants.LoginType;
import com.eactive.apim.portal.apps.login.service.DuplicateLoginService;
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
@@ -29,7 +30,9 @@ import java.io.IOException;
* 비워 사용자를 익명으로 되돌린 뒤 {@code /login?twofactor=1} 로 보낸다. 로그인 페이지가
* 공통 2FA 팝업을 자동 오픈하고, 인증 성공 시 {@code TwoFactorService} 가 최종 확정한다.</p>
*
* <p>2FA off(또는 DORMANT)면 {@link LoginFinalizer} 로 기존과 동일하게 즉시 확정한다.
* <p>2FA off 면 동시 접속(다른 곳 활성 세션) 여부를 확인해, 있으면 확정을 보류하고
* {@code /login?duplicate=1} 확인 팝업으로 유도한다({@link DuplicateLoginService}).
* 없으면(또는 DORMANT) {@link LoginFinalizer} 로 기존과 동일하게 즉시 확정한다.
* 실질 후처리 로직은 모두 {@link LoginFinalizer} 로 이관되어 로그인/2FA/가입자동로그인이 공유한다.</p>
*/
@Service
@@ -41,6 +44,7 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
private final LoginFinalizer loginFinalizer;
private final TwoFactorService twoFactorService;
private final TwoFactorProperties twoFactorProperties;
private final DuplicateLoginService duplicateLoginService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
@@ -71,6 +75,23 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
return;
}
// 2FA off: 동시 접속(다른 곳 활성 세션)이 있으면 확정을 보류하고 확인 팝업으로 유도한다.
// 중복 확인은 비밀번호 검증 통과 후에만 노출한다(사전 체크는 접속 여부/IP 정보 노출).
if (!dormant && duplicateLoginService.hasActiveSession(normalizedUsername)) {
user.setLoginFailureCount(0);
portalUserRepository.save(user);
HttpSession session = request.getSession();
duplicateLoginService.begin(session, user);
// 확인 완료 전까지 익명 상태로 되돌린다(보호 경로 자동 차단, LoginHandler 튕김 회피).
SecurityContextHolder.clearContext();
session.removeAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
response.sendRedirect(request.getContextPath() + "/login?duplicate=1");
return;
}
// 2FA off (또는 DORMANT) → 기존과 동일하게 즉시 확정
String redirect = loginFinalizer.finalizeLogin(user, username, request, LoginType.NORMAL);
response.sendRedirect(redirect);
@@ -108,7 +108,6 @@ public class PortalConfigSecurity {
.csrf(csrf -> csrf
.csrfTokenRepository(csrfTokenRepository)
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
)
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
@@ -6,6 +6,7 @@ import java.util.List;
import java.util.concurrent.TimeUnit;
import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletComponentScan;
import org.springframework.context.annotation.Bean;
@@ -26,6 +27,7 @@ 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;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
import org.springframework.web.servlet.resource.PathResourceResolver;
import org.springframework.web.servlet.resource.ResourceUrlEncodingFilter;
import org.springframework.web.servlet.resource.VersionResourceResolver;
@@ -68,6 +70,33 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
return new LayoutDialect();
}
/**
* redirect 응답에 전역 {@code @ModelAttribute}(브레드크럼용 pageName·showTestAuthNotice·
* testAuthNumber·sessionTimeoutMinutes 등)가 쿼리스트링으로 노출되는 것을 차단한다.
*
* <p>Spring Boot 2.6+ 에서 {@code spring.mvc.ignore-default-model-on-redirect} 프로퍼티가
* 제거됐고, 본 애플리케이션은 {@code @EnableWebMvc} 로 Boot 자동설정을 우회하므로
* {@link RequestMappingHandlerAdapter} 의 프레임워크 기본값(false)이 적용된다. 그 결과
* {@code redirect:} 를 반환하는 모든 컨트롤러에서 {@link com.eactive.apim.portal.common.breadcrumb.GlobalControllerAdvice}
* 의 기본 모델 값이 redirect URL 에 append 되어 프론트 전용 파라미터가 주소창에 그대로 드러난다.
* 어댑터 생성 이후 플래그만 true 로 뒤집어(Boot/기존 설정은 그대로 유지) 누수를 막는다.</p>
*
* <p>{@code static} 메서드로 선언해 이 설정 클래스가 조기 초기화되는 것을 피한다.
* 명시적 {@code RedirectAttributes}/flash 속성은 영향받지 않는다(기본 모델만 무시).</p>
*/
@Bean
public static BeanPostProcessor ignoreDefaultModelOnRedirectPostProcessor() {
return new BeanPostProcessor() {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof RequestMappingHandlerAdapter) {
((RequestMappingHandlerAdapter) bean).setIgnoreDefaultModelOnRedirect(true);
}
return bean;
}
};
}
// -------------------------------------------------------------
// RequestMappingHandlerMapping 설정 View Controller 추가
// -------------------------------------------------------------
@@ -25,8 +25,6 @@ public class PortalProperties {
private String authVirtualCode = "";
private boolean testAuthNoticeEnabled = false;
private Map<RoleCode, List<String>> portalSecurity;
private FileProperties file = new FileProperties();
-1
View File
@@ -40,7 +40,6 @@ gateway:
portal:
# auth-virtual-code: 654321
test-auth-notice-enabled: true
dev:
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
hot-reload-pages: true
-3
View File
@@ -28,9 +28,6 @@ spring:
thymeleaf:
cache: false
portal:
test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
app:
resource-versioning:
enabled: true
-1
View File
@@ -22,7 +22,6 @@ spring:
cache: false
portal:
test-auth-notice-enabled: true
# 검증 단계에선 사용하지 않음
# auth-virtual-code: 654321
dev:
-1
View File
@@ -112,7 +112,6 @@ portal:
auth-ttl: 300
auth:
resend_limit_seconds: 30
test-auth-notice-enabled: true # 기본값: true (대부분의 개발환경에서 UI 안내 표시)
user-approval: true
password-expiration-days: 90
+109 -46
View File
@@ -1160,7 +1160,7 @@ hr {
transition: all 0.3s ease;
}
.mobile-drawer .drawer-welcome .btn-drawer-login:hover {
background: rgb(0, 65.7, 162);
background: rgb(0%, 25.7647058824%, 63.5294117647%);
}
.mobile-drawer .drawer-welcome.authenticated {
flex-direction: row;
@@ -2456,7 +2456,7 @@ hr {
color: #FFFFFF;
}
.btn-success:hover {
background: rgb(83.2897959184, 199.3102040816, 106.493877551);
background: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
transform: translateY(-3px);
}
.btn-danger {
@@ -2464,7 +2464,7 @@ hr {
color: #FFFFFF;
}
.btn-danger:hover {
background: rgb(255, 70.8, 70.8);
background: rgb(100%, 27.7647058824%, 27.7647058824%);
transform: translateY(-3px);
}
.btn-ghost {
@@ -2730,7 +2730,7 @@ hr {
.action-btn-delete:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(255, 88.9, 88.9);
background: rgb(100%, 34.862745098%, 34.862745098%);
}
.action-btn-delete:active {
transform: translateY(0);
@@ -2848,7 +2848,7 @@ hr {
background: #a4d6ea;
}
.btn-input-action.btn-change:hover {
background: rgb(131.6625, 199.4303571429, 226.5375);
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
}
@@ -2923,7 +2923,7 @@ hr {
border: none;
}
.btn-action-primary:hover {
background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
background: rgb(12.4992826399%, 36.3615494978%, 80.6771879484%);
transform: translateY(-2px);
color: #fff;
}
@@ -2973,7 +2973,7 @@ hr {
}
.status-badge.status-processing {
background: rgba(255, 217, 61, 0.1);
color: rgb(221.2, 177.8721649485, 0);
color: rgb(86.7450980392%, 69.7537901759%, 0%);
}
.status-badge.status-failed {
background: rgba(255, 107, 107, 0.1);
@@ -3013,7 +3013,7 @@ hr {
}
.status-badge-header.status-processing {
background: rgba(255, 217, 61, 0.1);
color: rgb(221.2, 177.8721649485, 0);
color: rgb(86.7450980392%, 69.7537901759%, 0%);
}
.badge-sm {
@@ -4201,7 +4201,7 @@ select.form-control {
.file-upload-wrapper .file-remove-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(255, 88.9, 88.9);
background: rgb(100%, 34.862745098%, 34.862745098%);
}
.file-upload-wrapper .file-remove-btn:active {
transform: translateY(0);
@@ -4522,7 +4522,7 @@ select.form-control {
transition: all 0.3s ease;
}
.form-actions--with-withdrawal .withdrawal-link:hover {
background: rgb(210.2090909091, 232.6045454545, 242.9409090909);
background: rgb(82.4349376114%, 91.2174688057%, 95.2709447415%);
}
.form-actions--with-withdrawal .withdrawal-link img {
width: 22px;
@@ -4677,7 +4677,7 @@ select.form-control {
text-decoration: underline;
}
.notice-content-box a:hover {
color: rgb(0, 65.7, 162);
color: rgb(0%, 25.7647058824%, 63.5294117647%);
}
.form-row--content .form-label-wrapper {
@@ -5611,7 +5611,7 @@ select.form-control {
font-size: 16px;
}
.drawer-logout-btn:hover {
background: rgb(255, 70.8, 70.8);
background: rgb(100%, 27.7647058824%, 27.7647058824%);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
}
@@ -6324,7 +6324,7 @@ select.form-control {
color: #64748b;
}
.list-table-btn--default:hover {
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
}
.list-table-btn--primary {
background-color: #ecf0fa;
@@ -6332,7 +6332,7 @@ select.form-control {
color: #2a69de;
}
.list-table-btn--primary:hover {
background-color: rgb(216.7625, 224.8125, 244.9375);
background-color: rgb(85.0049019608%, 88.1617647059%, 96.0539215686%);
}
.list-table-btn--secondary {
background-color: #f5f5f4;
@@ -6340,7 +6340,7 @@ select.form-control {
color: #64748b;
}
.list-table-btn--secondary:hover {
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
}
.list-table-btn--danger {
background-color: #fbe7e9;
@@ -6348,7 +6348,7 @@ select.form-control {
color: #bb1026;
}
.list-table-btn--danger:hover {
background-color: rgb(247.5571428571, 210.3428571429, 214.0642857143);
background-color: rgb(97.081232493%, 82.487394958%, 83.9467787115%);
}
.table-pagination {
@@ -6998,7 +6998,7 @@ select.form-control {
.alert.alert-error {
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
color: rgb(255, 70.8, 70.8);
color: rgb(100%, 27.7647058824%, 27.7647058824%);
align-items: center;
}
.alert.alert-error svg {
@@ -7012,7 +7012,7 @@ select.form-control {
.alert.alert-success {
background: rgba(107, 207, 127, 0.1);
border: 1px solid rgba(107, 207, 127, 0.3);
color: rgb(61.5183673469, 189.6816326531, 87.1510204082);
color: rgb(24.12484994%, 74.3849539816%, 34.1768707483%);
}
.alert.alert-info {
background: rgba(0, 73, 180, 0.1);
@@ -7913,6 +7913,69 @@ button.djb-comment-submit:disabled {
margin-bottom: 22px;
}
}
.password-policy-checklist {
list-style: none;
padding: 0;
margin: 12px 0 0 0;
}
.password-policy-checklist li {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
line-height: 20px;
margin-bottom: 6px;
transition: color 0.15s ease;
}
.password-policy-checklist li:last-child {
margin-bottom: 0;
}
.password-policy-checklist li .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;
}
.password-policy-checklist li .policy-icon::before {
content: "•";
}
.password-policy-checklist li.is-idle {
color: #888;
}
.password-policy-checklist li.is-idle .policy-icon {
color: #b5b5b5;
}
.password-policy-checklist li.is-pass {
color: #1a8f4c;
}
.password-policy-checklist li.is-pass .policy-icon {
color: #1a8f4c;
}
.password-policy-checklist li.is-pass .policy-icon::before {
content: "✔";
}
.password-policy-checklist li.is-fail {
color: #d63a3a;
}
.password-policy-checklist li.is-fail .policy-icon {
color: #d63a3a;
}
.password-policy-checklist li.is-fail .policy-icon::before {
content: "✖";
}
.password-policy-note {
margin: 10px 0 0 0;
font-size: 14px;
line-height: 18px;
color: #888;
}
.hero-carousel-section {
position: relative;
width: 100%;
@@ -11931,10 +11994,10 @@ body.index-page-body {
line-height: 20px;
}
.login-button:hover {
background: rgb(25.65, 70.3, 173.85);
background: rgb(10.0588235294%, 27.568627451%, 68.1764705882%);
}
.login-button:active {
background: rgb(24.3, 66.6, 164.7);
background: rgb(9.5294117647%, 26.1176470588%, 64.5882352941%);
}
.login-button:disabled {
opacity: 0.6;
@@ -11977,10 +12040,10 @@ body.index-page-body {
border-bottom-right-radius: 8px;
}
.login-links-container .link-btn:hover {
background: rgb(220.61, 227.85, 245.95);
background: rgb(86.5137254902%, 89.3529411765%, 96.4509803922%);
}
.login-links-container .link-btn:active {
background: rgb(205.22, 215.7, 241.9);
background: rgb(80.4784313725%, 84.5882352941%, 94.862745098%);
}
.login-alert {
@@ -12475,11 +12538,11 @@ body.index-page-body {
}
.auth-request-button:hover,
.auth-verify-button:hover {
background: rgb(147.83125, 206.7151785714, 230.26875);
background: rgb(57.9730392157%, 81.0647759104%, 90.3014705882%);
}
.auth-request-button:active,
.auth-verify-button:active {
background: rgb(131.6625, 199.4303571429, 226.5375);
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
}
.auth-request-button:disabled,
.auth-verify-button:disabled {
@@ -12528,20 +12591,20 @@ body.index-page-body {
background: #e5e7eb;
}
.account-recovery-card .form-actions .cancel-button:hover {
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
}
.account-recovery-card .form-actions .cancel-button:active {
background: rgb(202.7739130435, 206.7913043478, 214.8260869565);
background: rgb(79.5191815857%, 81.094629156%, 84.2455242967%);
}
.account-recovery-card .form-actions .submit-button {
color: #FFFFFF;
background: #0049B4;
}
.account-recovery-card .form-actions .submit-button:hover {
background: rgb(0, 69.35, 171);
background: rgb(0%, 27.1960784314%, 67.0588235294%);
}
.account-recovery-card .form-actions .submit-button:active {
background: rgb(0, 65.7, 162);
background: rgb(0%, 25.7647058824%, 63.5294117647%);
}
.account-recovery-card .form-actions .submit-button:disabled {
opacity: 0.6;
@@ -12750,7 +12813,7 @@ body.index-page-body {
transition: color 0.3s ease;
}
.result-info-box .info-text .info-link:hover {
color: rgb(0, 65.7, 162);
color: rgb(0%, 25.7647058824%, 63.5294117647%);
}
@media (max-width: 576px) {
.result-info-box .info-text {
@@ -17967,7 +18030,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.btn-copy-action:hover {
background: rgb(131.6625, 199.4303571429, 226.5375);
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
}
@media (max-width: 768px) {
.btn-copy-action {
@@ -17994,7 +18057,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.btn-view-secret:hover {
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
}
.btn-view-secret svg {
width: 20px;
@@ -18179,7 +18242,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border-radius: 8px;
}
.btn-copy-action:hover {
background: rgb(131.6625, 199.4303571429, 226.5375);
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
}
.btn-view-secret {
width: 100% !important;
@@ -18195,7 +18258,7 @@ input[type=checkbox]:checked + .custom-checkbox {
height: 16px;
}
.btn-view-secret:hover {
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
}
#revealedSecretBox {
width: 100%;
@@ -18468,7 +18531,7 @@ input[type=checkbox]:checked + .custom-checkbox {
flex-shrink: 0;
}
.detail-wrap .dt-btn-copy:hover {
background: rgb(189.6, 230.0857142857, 255);
background: rgb(74.3529411765%, 90.2296918768%, 100%);
}
.detail-wrap .dt-btn-copy svg {
color: #2a69de;
@@ -18645,7 +18708,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.detail-wrap .dt-btn-gray:hover {
background: rgb(170.6589473684, 183.4378947368, 193.6610526316);
background: rgb(66.9250773994%, 71.9364293086%, 75.9455108359%);
}
.detail-wrap .dt-btn-red {
width: 156px;
@@ -18663,7 +18726,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.detail-wrap .dt-btn-red:hover {
background: rgb(255, 70.0915337423, 64.24);
background: rgb(100%, 27.4868759774%, 25.1921568627%);
}
.detail-wrap .dt-btn-blue {
width: 156px;
@@ -20138,7 +20201,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-list:hover {
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
}
.btn-inquiry-list:active {
transform: scale(0.98);
@@ -20171,7 +20234,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-edit:hover {
background: rgb(0, 69.35, 171);
background: rgb(0%, 27.1960784314%, 67.0588235294%);
}
.btn-inquiry-edit:active {
transform: scale(0.98);
@@ -20204,7 +20267,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-delete:hover {
background: rgb(217.9841772152, 41.3658227848, 58.2873417722);
background: rgb(85.4839910648%, 16.2218912882%, 22.8577810871%);
}
.btn-inquiry-delete:active {
transform: scale(0.98);
@@ -20276,7 +20339,7 @@ input[type=checkbox]:checked + .custom-checkbox {
margin-left: 8px;
}
.file-upload-inline .btn-remove-file-inline:hover {
background: rgb(209.4151898734, 36.2848101266, 52.8721518987);
background: rgb(82.1236038719%, 14.2293373045%, 20.7341772152%);
}
.file-upload-inline .btn-remove-file-inline svg {
width: 12px;
@@ -20308,7 +20371,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.file-upload-inline .btn-file-attach:hover {
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
}
.file-upload-inline .btn-file-attach svg {
width: 22px;
@@ -20368,7 +20431,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none;
}
.inquiry-form-container .form-actions .btn-secondary:hover {
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
}
.inquiry-form-container .form-actions .btn-primary {
background: #0049b4;
@@ -20376,7 +20439,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none;
}
.inquiry-form-container .form-actions .btn-primary:hover {
background: rgb(0, 69.35, 171);
background: rgb(0%, 27.1960784314%, 67.0588235294%);
}
.inquiry-form-container .file-upload-inline .file-input-display {
min-height: 50px;
@@ -21165,7 +21228,7 @@ input[type=checkbox]:checked + .custom-checkbox {
cursor: pointer;
}
.djb-board-write-container .form-actions .btn-submit:hover {
background-color: rgb(33.643902439, 97.8731707317, 217.156097561);
background-color: rgb(13.193687231%, 38.3816355811%, 85.1592539455%);
}
@media (max-width: 768px) {
.djb-board-write-container .form-actions .btn-submit {
@@ -21653,7 +21716,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: all 0.3s ease;
}
.org-file-remove:hover {
background: rgb(255, 70.8, 70.8);
background: rgb(100%, 27.7647058824%, 27.7647058824%);
}
.org-file-notice {
@@ -22664,7 +22727,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.status-indicator.status-active {
background-color: rgba(107, 207, 127, 0.1);
color: rgb(83.2897959184, 199.3102040816, 106.493877551);
color: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
}
.status-indicator.status-active .status-dot {
background-color: #6BCF7F;
@@ -672,9 +672,36 @@ const customPopups = {
$('body').css('overflow', 'hidden');
// 열 때마다 사유/에러 초기화
$('#withdrawalReasonInput').val('');
$('#withdrawalReasonError').hide();
$('#withdrawalReasonInput').off('input.withdrawal').on('input.withdrawal', function () {
$('#withdrawalReasonError').hide();
});
$('#withdrawalPopupConfirmButton').off('click').on('click', function () {
// 탈퇴 사유 필수 입력
var reason = $.trim($('#withdrawalReasonInput').val() || '');
if (!reason) {
$('#withdrawalReasonError').show();
$('#withdrawalReasonInput').focus();
return;
}
$('#withdrawalReasonHidden').val(reason);
customPopups.hideWithdrawal();
$('#withdrawalForm').submit();
// step-up 2FA 활성 시: 인증 성공 후에만 제출
var twofaRequired = $('#withdrawalPopup').attr('data-twofa-required') === 'true';
if (twofaRequired && typeof TwoFactorAuth !== 'undefined') {
TwoFactorAuth.open({
purpose: '/withdraw',
onSuccess: function () { $('#withdrawalForm').submit(); },
onCancel: function () { /* 사용자 취소 — 탈퇴 중단 */ }
});
} else {
$('#withdrawalForm').submit();
}
});
$('#withdrawalPopupCancelButton').off('click').on('click', function () {
+1
View File
@@ -47,6 +47,7 @@
@use 'components/djb-inquiry-comments' as *;
@use 'components/board-common' as *;
@use 'components/two-factor' as *;
@use 'components/password-policy' as *;
// 5. Page-specific styles
@use 'pages/index' as *;
@@ -32,7 +32,7 @@
<div class="found-users-list">
<div class="found-user-item" th:each="user, stat : ${foundUsers}">
<div class="user-info">
<span class="user-email" th:text="${user.maskedEmailAddr}">te***@example.com</span>
<span class="user-email" th:text="${user.loginId}">test@example.com</span>
<span class="user-date">
(<th:block th:text="${#temporals.format(user.createdDate, 'yyyy.MM.dd')}">2024.01.01</th:block> 가입)
</span>
@@ -149,34 +149,21 @@
});
}
// 로그인 전 중복 접속 확인 → 중복 시 기존 세션 강제 로그아웃 여부 질의
function checkDuplicateAndLogin(form) {
var loginId = form.id.value;
$.ajax({
url: /*[[@{/api/session/check-duplicate}]]*/ '/api/session/check-duplicate',
type: 'POST',
data: { loginId: loginId },
success: function (data) {
if (data && data.duplicateSession) {
$('#loginLoading').removeClass('active');
var msg = '해당 계정은 이미 다른 곳(' + data.ipAddress + ')에서 접속 중입니다.<br>'
+ '접속 시각: ' + data.loginTime + '<br><br>'
+ '기존 접속을 해제하고 로그인하시겠습니까?';
customPopups.showConfirm(msg, function (confirmed) {
if (confirmed) {
$('#loginLoading').addClass('active');
form.submit();
}
});
} else {
form.submit();
}
},
error: function () {
// 중복 체크 실패 시 로그인은 그대로 진행
form.submit();
}
});
// 동시 접속 확인은 비밀번호 검증(1차 인증) 통과 후 서버가 안내한다
// (2FA pending 또는 /login?duplicate=1 대기 상태에서 duplicateInfo 모델로 수신).
function duplicateConfirmMessage(info) {
return '해당 계정은 이미 다른 곳(' + info.ipAddress + ')에서 접속 중입니다.<br>'
+ '접속 시각: ' + info.loginTime + '<br><br>'
+ '기존 접속을 해제하고 로그인하시겠습니까?';
}
// 세션 CSRF 헤더 (meta[name=_csrf]) — 인증 후 확인/취소 POST 용
function csrfHeaders() {
var t = document.querySelector('meta[name="_csrf"]');
var h = document.querySelector('meta[name="_csrf_header"]');
var headers = {};
headers[h ? h.getAttribute('content') : 'X-XSRF-TOKEN'] = t ? t.getAttribute('content') : '';
return headers;
}
function fnInit() {
@@ -242,7 +229,7 @@
customPopups.showAlert('[[#{login.passLengthShort}]]');
} else {
refreshCsrfAndThen(form, function () {
checkDuplicateAndLogin(form);
form.submit();
});
}
}
@@ -260,9 +247,12 @@
}
});
// 로그인 2FA: 1차 인증 통과 후 pending 상태면 추가 인증 팝업 자동 오픈
// 1차 인증(비밀번호) 통과 후 흐름: [동시 접속 확인] → [2FA] 순서.
var twoFactorPending = [[${twoFactorPending}]];
if (twoFactorPending && typeof TwoFactorAuth !== 'undefined') {
var duplicateConfirmPending = [[${duplicateConfirmPending}]];
var duplicateInfo = [[${duplicateInfo}]];
function openLoginTwoFactor() {
TwoFactorAuth.open({
mode: 'login',
onSuccess: function (res) {
@@ -279,6 +269,53 @@
});
}
if (twoFactorPending && typeof TwoFactorAuth !== 'undefined') {
// 로그인 2FA pending: 동시 접속이 있으면 확인 후 2FA 팝업, 취소 시 로그인 포기
if (duplicateInfo) {
customPopups.showConfirm(duplicateConfirmMessage(duplicateInfo), function (confirmed) {
if (confirmed) {
openLoginTwoFactor();
} else {
$.ajax({
url: /*[[@{/auth/2fa/cancel}]]*/ '/auth/2fa/cancel',
type: 'POST',
headers: csrfHeaders(),
data: { reason: 'CANCELLED' }
});
customPopups.showAlert('로그인이 취소되었습니다.');
}
});
} else {
openLoginTwoFactor();
}
} else if (duplicateConfirmPending && duplicateInfo) {
// 2FA off + 동시 접속: 확인 시 서버가 로그인 확정(기존 접속 해제), 취소 시 포기
customPopups.showConfirm(duplicateConfirmMessage(duplicateInfo), function (confirmed) {
var url = confirmed
? /*[[@{/login/duplicate/confirm}]]*/ '/login/duplicate/confirm'
: /*[[@{/login/duplicate/cancel}]]*/ '/login/duplicate/cancel';
$.ajax({
url: url,
type: 'POST',
headers: csrfHeaders(),
success: function (res) {
if (confirmed) {
if (res && res.valid && res.redirect) {
window.location.href = res.redirect;
} else {
customPopups.showAlert((res && res.message) || '로그인 확인이 만료되었습니다. 다시 로그인해주세요.');
}
}
},
error: function () {
if (confirmed) {
customPopups.showAlert('로그인 처리 중 오류가 발생했습니다. 다시 로그인해주세요.');
}
}
});
});
}
fnInit();
});
</script>
@@ -143,13 +143,6 @@
</div>
</div>
<!-- Call Back URL -->
<div class="s1-field">
<label class="s1-label">Call Back URL</label>
<input type="text" id="callbackUrl" name="callbackUrl" th:field="*{callbackUrl}" class="s1-input"
placeholder="URL을 입력해 주세요.">
</div>
<!-- 화이트 리스트 -->
<div class="s1-field">
<label class="s1-label">화이트 리스트 <span class="s1-required">*</span></label>
@@ -357,7 +350,6 @@
form.addEventListener('submit', function (e) {
const name = document.getElementById('appName').value.trim();
const desc = textarea.value.trim();
const url = document.getElementById('callbackUrl').value.trim();
if (!name) {
e.preventDefault();
@@ -372,15 +364,6 @@
textarea.focus();
return;
}
if (url) {
try { new URL(url); } catch (_) {
e.preventDefault();
customPopups.showAlert('올바른 URL 형식이 아닙니다.\n예: https://example.com/callback');
document.getElementById('callbackUrl').focus();
return;
}
}
});
});
</script>
@@ -111,6 +111,38 @@
</div>
</div>
</div>
<script th:if="${error}" th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
customPopups.showAlert(/*[[${error}]]*/ '');
});
</script>
<script th:inline="javascript">
// 반영 직전 2FA: twofaRequired 면 최종 저장 제출을 가로채 2FA 팝업 → 성공 시 실제 제출.
// api-selector.js 의 submit 리스너(선택 검증·hidden 동기화)가 먼저 실행된 뒤 동작한다.
// "이전" 버튼(btnPrevStep)은 form.submit() 직접 호출이라 submit 이벤트를 타지 않음 → 2FA 미적용.
document.addEventListener('DOMContentLoaded', function () {
var twofaRequired = /*[[${twofaRequired}]]*/ false;
if (!twofaRequired) return;
var form = document.getElementById('apiSelectorForm');
if (!form) return;
form.addEventListener('submit', function (e) {
// 앞선 리스너가 검증 실패로 막았거나(미선택 등) 이미 취소된 제출이면 개입하지 않는다.
if (e.defaultPrevented) return;
e.preventDefault();
if (typeof TwoFactorAuth === 'undefined') { form.submit(); return; }
TwoFactorAuth.open({
mode: 'stepup',
purpose: '/myapikey/modify/step2',
// form.submit() 은 submit 이벤트를 재발생시키지 않으므로 그대로 서버로 전송된다.
onSuccess: function () { form.submit(); },
onCancel: function () { /* 사용자 취소 — step2 유지 */ }
});
});
});
</script>
</th:block>
<!-- 화면 전체 오버레이/플로팅은 body 직속(pagePopups)으로 렌더 → wrapper transform·overflow 영향 없이 뷰포트 기준 중앙 정렬 -->
@@ -151,7 +151,7 @@
</div>
<div class="s3-message-wrapper">
<h1 class="s3-success-title">앱 수정이 완료되었습니다.</h1>
<h1 class="s3-success-title">앱 수정 신청이 완료되었습니다.</h1>
<p class="s3-success-desc">
<span class="s3-highlight">담당자 승인 후 변경 사항이 적용됩니다.</span>
<br>
@@ -22,7 +22,6 @@
<form id="passwordChangeForm" th:action="@{/password/change}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="register-form-container">
<div class="info-notice-box">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef"
@@ -31,7 +30,8 @@
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
<p>소중한 계정 보호를 위해 비밀번호를 변경해 주세요!</p>
<p>소중한 계정 보호를 위해 비밀번호를 변경해 주세요!<br>
비밀번호 변경이 완료되면 자동으로 로그아웃되며, 새 비밀번호로 다시 로그인해야 합니다.</p>
</div>
<div class="form-row">
@@ -27,7 +27,10 @@
<input type="hidden" name="registrationType" th:value="${registrationType}" />
<input type="hidden" id="registrationScenario" name="registrationScenario" value="new" />
<div id="email-validation" class="org-validation-message"></div>
</div>
<div id="emailTestNotice" class="test-env-notice"
style="display: none; margin-top: 8px; padding: 10px 12px; background-color: #FFF4E6; border: 1px solid #FFB84D; border-radius: 4px; font-size: 13px; color: #5D4037; line-height: 1.5;"></div>
<input type="hidden" id="emailVerified" name="emailVerified" value="false"/>
<div id="email-auth-validation" class="org-validation-message"></div>
</div>
<!-- 이메일 인증 (중복체크 통과 후 노출) -->
@@ -48,8 +51,6 @@
</div>
<button type="button" class="btn-action-primary md" id="btnVerifyEmailCode">인증확인</button>
</div>
<input type="hidden" id="emailVerified" name="emailVerified" value="false" />
<div id="email-auth-validation" class="org-validation-message"></div>
</div>
</div>
</th:block>
@@ -239,6 +240,12 @@
setEmailAuthMsg('인증번호를 발송했습니다.', false);
btn.text('인증번호 재발송');
startEmailCodeTimer();
// 테스트 환경(PTL_PROPERTY auth.test-notice.enabled)일 때만 인증번호 노출
if (res.authNumber) {
$('#emailTestNotice').html('테스트 환경입니다. 실제 이메일이 발송되지 않으며, 인증번호는 <strong style="color:#E65100; font-family:monospace; font-size:14px;">' + res.authNumber + '</strong> 입니다.').show();
} else {
$('#emailTestNotice').hide().empty();
}
} else {
setEmailAuthMsg((res && res.message) || '발송에 실패했습니다.', true);
}
@@ -45,6 +45,8 @@
</div>
<button type="button" class="btn org-btn-check" id="btnVerifyEmailCode">인증확인</button>
</div>
<div id="emailTestNotice" class="test-env-notice"
style="display: none; margin-top: 8px; padding: 10px 12px; background-color: #FFF4E6; border: 1px solid #FFB84D; border-radius: 4px; font-size: 13px; color: #5D4037; line-height: 1.5;"></div>
<input type="hidden" id="emailVerified" name="emailVerified" value="false"/>
<div id="email-auth-validation" class="org-validation-message"></div>
</div>
@@ -122,8 +124,9 @@
if (emailChangeForm) emailChangeForm.style.display = 'none';
// 중복체크 통과 → 이메일 인증 UI 노출
// (block 지정 시 .org-form-group 의 display:flex 를 덮어써 라벨/입력이 세로로 깨짐 → '' 로 CSS flex 복원)
var evRow = document.getElementById('emailVerifyRow');
if (evRow) evRow.style.display = 'block';
if (evRow) evRow.style.display = '';
break;
case "conversionOrChange":
@@ -296,6 +299,17 @@
setEmailAuthMsg('인증번호를 발송했습니다.', false);
btn.textContent = '인증번호 재발송';
startEmailCodeTimer();
// 테스트 환경(PTL_PROPERTY auth.test-notice.enabled)일 때만 인증번호 노출
var notice = document.getElementById('emailTestNotice');
if (notice) {
if (res.authNumber) {
notice.innerHTML = '테스트 환경입니다. 실제 이메일이 발송되지 않으며, 인증번호는 <strong style="color:#E65100; font-family:monospace; font-size:14px;">' + res.authNumber + '</strong> 입니다.';
notice.style.display = '';
} else {
notice.style.display = 'none';
notice.innerHTML = '';
}
}
} else {
setEmailAuthMsg((res && res.message) || '발송에 실패했습니다.', true);
}
@@ -24,19 +24,15 @@
<image id="image0_1130_6799" width="163" height="129" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKMAAACBCAYAAACsCAq9AAAACXBIWXMAABcSAAAXEgFnn9JSAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAACtpJREFUeNrsnUFoY1UUhm8GXYgwFmcxG2FSBZEBp6m60IU0AREGhCZrF003umzrxpW20Y1uTAtudJN04bodGBBEaLrShdoMwiAI9g24mYUQBXXhxnuSk87rTJPc8969L/e+9x94lOkkzXt53/vPOfeee65SMJgnVsJXcN6++emXsv5BR0UfCxNe1tfH4M2XXujhGwOMtuGr62NFH9UpAKopYNJxSx89DegAWAFGCYAEXFMfa6yANq2rj32oJmA0gbDNSrjg+OMifbQ0lF1gBhgfhnBTHxsZQHiRG9+CUgJGApHiwA4nJfO0XVZKxJRFhFGDuKN/bHt0SuS6GxrIPtArCIzslg84O/bR1hFLPmqXcgrikccgknVYtWF5VcYYiJVATrmrFXIdGOZMGQMEkaypz3sTGObPTdsCMeLMlxSrRodWL/Igi+N/U1asj0N92MiM2xrIJlDMiZvWN5MGstMoDEFFCcWeBi8SfjYNoNNMTj3l59eKnmWXcgBinTPnpBDskRKmHf/jOe5OisSpr89hGTCGHSeeqmSzKuRm120PQvPD0Ul4TjQovgMYi+WeaXpu19NkalEaKgDG+YNYZlWUWmYDzvocSSGlyQmVodWQTYdl2z6DSMZjiNLPq/J8OmAMSBWbPoMYDwnUqHJHYhuAMRyT3qzDec0Fc4LUULIxyTo/cIAxAJOoIkEw1yk3TkhawrfVAaP/Llpapb3lQw0hZ+8Sd70GGP23FcFrI89KtSTqWCmaqw4RRkmmuefTiesHgwbaI0fXChgzdtHkniUDyV0PL0PygKwARn9NAqKva5h7jq4XMHoM47GPF8CVORFgDB9GSRbd8/g6jONGDk0Ao4e25OKGz8Ekql0BjIErY1ErXwAjDAYYYYARBgOMoiy0jNsLGF3aPcFrfYZRMrPSB4x+mmRGperxdRgP1xSpa1loMEpUYtXHC9Dhw7Re4Q9bD27aUxM23PS1BEtSpxgBxvyoY9PD85dUcB8DRr9Noo5eVUtzT52yo2sFjHOwfUlG7VkfRMny2n7RpjSDg1FYgkW24UPsyA9F2dFDBxgDUUfKXDseZNDSpgNdwBiGSfvkVOflrmP9xUUgFnFXhCBh5BslVY7trJtyxhpAScOEliqghVwoQTdMqh6drIBM0YmsW9RazGBh5BuWZClqh1vpuY4Rk4A4KKoqkuWhc+2pSlYUQVn5uu3WxdwwflslaxbqtG8kYMwmUz1J8Sco9myldY3cxq6tkq9ZKWxfxtzAyCDsqPTbshGUt7jrg+nnkiKPG8ynWThF7nm56Ot2crMpUcIusZPAINdN88KRenSAnaC7pkYlarZW7tWw62q+YAxxUyKlsI9g/mAMFEiAmFcYAwMSIOYdRgcxpG0bMIiHwO+85XapKu80sKXs7O9ny/qcNQPEIiljTCHJXbfV/BdoFXr3K8B4HsomQ5l1V68eu+UIuAHGh5Mbmq6jQepyBhC2MH4IGE2Vkpaz2tzigtSP4sE9KCFgTKqWVQazomRDQvHZmsOi7xcNGN0lPQvsyssXuN9hZlzEamwYDAaDwU27jxErMRd8LeaKq8JYUfHPP/nnANkzYJyVnBB8K/wzi3HGiOG8o0ZFswC0iDByxfWqsltvaMMIyFucdUdAL6cw8k6r4zHDEPZNIRgxHpkXGLnkv6mymU1xaeTO91gxB4AxPDe8pvwsD0tjA4aysOumS4FBuK2Kse1tV1lYsQgY3bjjtrI7hxySUu4WxX2XPIZwXGGzrYptFFM2iqCSlzwFkVzxCUAcGg1PHRThQksegthmRZyHW5RU3WQduzbyvlzhMc9iwwPlfqC6zwdtcNRToym8forzHs/qEJxL6sFUowuFzDWMJU9ArDKIC47go5mPzKblYtOQK5x42YAzXS+em7fjU6NLse+6GvuexonS8dlD+/VbUWFg5Ipr222OCbp9voGRB9dYVul78sgXdN28beNzxw9z1zWYpTnfJJtrmyMG0OtBY3brG0o+dWnej2cE4bayPzFAn9/SUPZyBaNFECNWjW5I8VFs6GrDAEozF+0OwougXLetlKWAQQwSwgRQ9jiTHswAMU2T0qRGKrkTLIwWQBzOTORtQTxDSd/Lauxhm90vcpSYdNT8ZqgopqxpKAdBwWihqefQPaDs6hyIPjS5GjCQ/SBgTJk1D9gl74LAMxDHTex9qd9MDWQpIxDT9N120ggeiugfkKUMQKQv7jThE2wWvANEn4xCqOUkMWQW04FJZ1a63NYOdt7ayu9GqGW+5+LZIqdVO7wnShUgWlPFugqjwr3KQ01+uOkUcSJAnOyeT1Q4630G7K6NRz5cKmMHIFq1TRXWwjN6eETDeE6Ukd2zdH8+Wh3XAHMTVTFpEjhvWzRVx0sOQBQ/EYqHb0DdVFVcCPTcjVlw4aalrYrH3f8xfDPZ1gI+9yYre7YwxhbWSwwD2rMzaC9ixYUnH1fVG1dU5dnL0rcazZvbHmeUJi2H2IZipq14IW9vPKPa714fAknW+/kP1fjoRzX4+z+Tt1PxRzczZeSlA1WpewZrM63qA4id95bOQBye1ItXhr+zqYw23bQ0aUGcaJZFz3W2Zeft5ydCV3/tquRaZl6HFTfNsaLkCe7BPRvZXEEkCEkVLV5LPwtllKriFjhzA+M4ycgCxO63v0v+5MwkLLUyxiqUTa2L7NmcLcmL2+9cV5v1xVFArhOLrS/uSoEZwnzw4cvDmHCa9X/7a/j3BXYtCzfdFL6+BcbcxHZjEMdQjWM9UyDpPUefvjpz6IZArL3/vWkmbayMNtz0hlAVI6Bj31ZuPD3R3ZokGqYgEtgJQDSyVDDycE5Z8Baooit/Hht2uQjIaZDR/512a0Ygrn92xwmINpRRMk3Vgyq6M3KdSVSPfkf/Nw3mOIguLS2MkuWRe0DGnVEyYQJk+eoTZ7+jrNsERILQAojHzmDkHQZMs70I44rJBM/0heQ6KZabBeTBB68Mf9KwzdEnZiBKM/J5KOOq4LUAMZmJwhoCsvHxD1NjOnLLJ5+/PnMqj/6GZRB7LmGsCl67D64S2GjJp2jKNLr/78xsN+6qp6msZUXsO4GR17eYZtERBrndKspFyUzS4RcTd59I4Q2WriZVRokqwkU7DvwnASlNOsYQWwbRmIGkMEpq7OCiM7iRF77xu/vGQDoE0ZgB18o4gItOHTdGaYA0GR9M49YNXXTfCYyxhupO4h3YhZZqjJaA3D08nfh/DkEkM551S6KMkrKmY3BkRR17aR/srS/vPgIk/dvl9B6rYtf0xUmqdpYEr4WLtqsw1bRAtr76VVWeu6yi+/8Mh4EyOGdjEy/i1276yPRL0fFiCQxZtJu3bTbkd209rYqi5k9J3LTp0wlVtG9UIR/CuqFEi+1EMPJaF7jo+cWOdJMbQTw0CXZCkCqjBMZ7oMdZMuPzEt9dSdKSBkZJJg1ldAdkV/lZqEy7aCVebCeFUdpDB+YOyB3PgCQQUym2FManoIzeAemDy26lBdGpm0a3iExd9rIS1j5azJobtnbJksIY4e57CWSfgcxynxx6CKgRqLWqLCmMpjVJKBvLHsgBJw+Ljr9/yuZrQ7dsYYu2uIlmSAR7utSy2mgcNsEe7LAq3Up4mhLuu9reVwwjAzlrmzA0ifcPTAKSalCrgri/rx5sfN6zrYJWYIwBOX7q4iffwirAIOAclwHGW+49WG/jUP2m2f8CDAD1bzaEOOLAWAAAAABJRU5ErkJggg=="/>
</defs>
</svg>
<h2 class="result-title" th:text="${registrationType == 'corporate'} ? '법인 신청이 완료 되었습니다.' : '회원가입 신청이 완료되었습니다.'">회원가입 신청이 완료되었습니다.</h2>
<h2 class="result-title" th:text="${registrationType == 'corporate'} ? '법인 신청이 완료 되었습니다.' : '회원가입이 완료되었습니다.'">회원가입이 완료되었습니다.</h2>
</div>
<!-- Info Box -->
<div class="result-info-box">
<p class="info-text" th:if="${registrationType == 'corporate'}">
<div class="result-info-box" th:if="${registrationType == 'corporate'}">
<p class="info-text">
관리자의 확인 및 승인 이후<br>
이용하실 수 있습니다.
</p>
<p class="info-text" th:unless="${registrationType == 'corporate'}">
API Portal 서비스를 이용하기 위해서<br>
<strong>이메일 인증</strong>을 완료해 주세요.
</p>
</div>
</div>
@@ -1,5 +1,6 @@
<!-- views/fragment/popup/withdrawalPopup.html -->
<div th:fragment="withdrawalPopup" id="withdrawalPopup" style="display: none;">
<div th:fragment="withdrawalPopup" id="withdrawalPopup" style="display: none;"
th:attr="data-twofa-required=${withdrawTwofaRequired}">
<!-- Modal Backdrop -->
<div class="modal-backdrop" id="withdrawalModalBackdrop"></div>
@@ -24,15 +25,25 @@
<li>회원 탈퇴는 API Portal 사이트의 회원 탈퇴입니다.</li>
<li>사용중인 API 서비스의 중지는 제휴 담당자를 통해 문의 주시기 바랍니다.</li>
</ul>
<div style="margin-top: 16px;">
<label for="withdrawalReasonInput" style="display: block; margin-bottom: 6px; font-size: 14px; font-weight: 600; color: #334155;">
탈퇴 사유 <span style="color: #d63a3a;">(필수)</span>
</label>
<textarea id="withdrawalReasonInput" maxlength="200" rows="3"
placeholder="탈퇴 사유를 입력해 주세요. (최대 200자)"
style="width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid #CBD5E1; border-radius: 6px; font-size: 14px; line-height: 1.5; resize: vertical;"></textarea>
<p id="withdrawalReasonError" style="display: none; margin: 6px 0 0 0; font-size: 13px; color: #d63a3a;">탈퇴 사유를 입력해 주세요.</p>
</div>
<form id="withdrawalForm" th:action="@{/withdraw}" method="POST" style="display:none;">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<input type="hidden" name="withdrawReason" id="withdrawalReasonHidden"/>
</form>
</div>
<!-- Modal Footer -->
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="withdrawalPopupCancelButton">취소</button>
<button type="button" class="btn btn-primary" id="withdrawalPopupConfirmButton">탈퇴신청</button>
<button type="button" class="btn btn-primary" id="withdrawalPopupConfirmButton">탈퇴하기</button>
</div>
</div>
@@ -2,6 +2,8 @@ package com.eactive.apim.portal.apps.user;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.controller.AccountController;
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
@@ -61,6 +63,12 @@ class AccountControllerTest {
@Mock
private UserSessionService userSessionService;
@Mock
private TwoFactorService twoFactorService;
@Mock
private TwoFactorProperties twoFactorProperties;
@InjectMocks
private AccountController accountController;
@@ -73,7 +81,7 @@ class AccountControllerTest {
@Test
void testShowChangePasswordPage() throws Exception {
mockMvc.perform(get("/change_password"))
mockMvc.perform(get("/password/verify"))
.andExpect(status().isOk())
.andExpect(view().name("apps/mypage/passwordChangeEntry"))
.andExpect(model().attributeExists("passwordChangeRequest"));
@@ -84,7 +92,7 @@ class AccountControllerTest {
try (MockedStatic<SecurityUtil> securityUtil = mockStatic(SecurityUtil.class)) {
securityUtil.when(SecurityUtil::getCurrentLoginId).thenReturn("testUser");
mockMvc.perform(post("/mypage/change_new_password")
mockMvc.perform(post("/password/change")
.param("newPassword", "NewPass!1")
.param("confirmPassword", "NewPass!1"))
.andExpect(status().is3xxRedirection())
@@ -99,7 +107,7 @@ class AccountControllerTest {
doThrow(new IllegalArgumentException("invalid password"))
.when(userFacade).updatePassword(eq("testUser"), anyString(), anyString());
mockMvc.perform(post("/mypage/change_new_password")
mockMvc.perform(post("/password/change")
.param("newPassword", "NewPass!1")
.param("confirmPassword", "Different!1"))
.andExpect(status().isOk())