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; *

검증 레벨(완화 정책)

* @@ -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> checkDuplicate(@RequestParam("loginId") String loginId) { - Map result = new HashMap<>(); - String normalizedLoginId = loginId != null ? loginId.toLowerCase() : ""; - - Optional 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 "***"; - } } diff --git a/src/main/java/com/eactive/apim/portal/apps/user/controller/AccountController.java b/src/main/java/com/eactive/apim/portal/apps/user/controller/AccountController.java index c0ec6f6..1a8b8bd 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/controller/AccountController.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/controller/AccountController.java @@ -136,7 +136,8 @@ public class AccountController { new SecurityContextLogoutHandler().logout(request, response, SecurityContextHolder.getContext().getAuthentication()); - redirectAttributes.addFlashAttribute("success", "비밀번호가 성공적으로 변경되었습니다."); + redirectAttributes.addFlashAttribute("success", + "비밀번호가 성공적으로 변경되었습니다.
새 비밀번호로 다시 로그인해주세요."); 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 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 { diff --git a/src/main/java/com/eactive/apim/portal/apps/user/controller/OrgRegisterController.java b/src/main/java/com/eactive/apim/portal/apps/user/controller/OrgRegisterController.java index 2fbe817..f58521c 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/controller/OrgRegisterController.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/controller/OrgRegisterController.java @@ -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( diff --git a/src/main/java/com/eactive/apim/portal/apps/user/controller/UserRegisterController.java b/src/main/java/com/eactive/apim/portal/apps/user/controller/UserRegisterController.java index b44e8a4..f3cd2d8 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/controller/UserRegisterController.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/controller/UserRegisterController.java @@ -146,12 +146,13 @@ public class UserRegisterController { if (invitationToken == null) { Optional 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) → 회원가입 직후 이메일 인증 단계로 이동(기존 흐름 유지) diff --git a/src/main/java/com/eactive/apim/portal/apps/user/facade/AuthFacadeImpl.java b/src/main/java/com/eactive/apim/portal/apps/user/facade/AuthFacadeImpl.java index 371f87d..b4ec6b1 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/facade/AuthFacadeImpl.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/facade/AuthFacadeImpl.java @@ -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()); diff --git a/src/main/java/com/eactive/apim/portal/apps/user/facade/OrgRegisterFacade.java b/src/main/java/com/eactive/apim/portal/apps/user/facade/OrgRegisterFacade.java index b7ac80a..e916f8c 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/facade/OrgRegisterFacade.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/facade/OrgRegisterFacade.java @@ -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( diff --git a/src/main/java/com/eactive/apim/portal/apps/user/facade/OrgRegisterFacadeImpl.java b/src/main/java/com/eactive/apim/portal/apps/user/facade/OrgRegisterFacadeImpl.java index f79b354..6c5f77c 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/facade/OrgRegisterFacadeImpl.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/facade/OrgRegisterFacadeImpl.java @@ -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); diff --git a/src/main/java/com/eactive/apim/portal/apps/user/facade/UserFacade.java b/src/main/java/com/eactive/apim/portal/apps/user/facade/UserFacade.java index 47cedb2..98cd3c2 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/facade/UserFacade.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/facade/UserFacade.java @@ -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); } \ No newline at end of file diff --git a/src/main/java/com/eactive/apim/portal/apps/user/facade/UserFacadeImpl.java b/src/main/java/com/eactive/apim/portal/apps/user/facade/UserFacadeImpl.java index 6d16ca1..40ab6a7 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/facade/UserFacadeImpl.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/facade/UserFacadeImpl.java @@ -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())); } diff --git a/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserAuthService.java b/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserAuthService.java index 3b7892b..fe791bd 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserAuthService.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserAuthService.java @@ -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"); } diff --git a/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserService.java b/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserService.java index 986dd69..6b58d2f 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserService.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserService.java @@ -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 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); diff --git a/src/main/java/com/eactive/apim/portal/common/breadcrumb/GlobalControllerAdvice.java b/src/main/java/com/eactive/apim/portal/common/breadcrumb/GlobalControllerAdvice.java index 6f9ab52..933992b 100644 --- a/src/main/java/com/eactive/apim/portal/common/breadcrumb/GlobalControllerAdvice.java +++ b/src/main/java/com/eactive/apim/portal/common/breadcrumb/GlobalControllerAdvice.java @@ -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"; diff --git a/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationManager.java b/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationManager.java index d8d65b5..f4eb214 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationManager.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationManager.java @@ -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("로그인할 수 없습니다. 관리자에게 문의하세요. (법인 승인대기중)"); diff --git a/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java b/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java index 4286d7a..0046269 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java @@ -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} 가 최종 확정한다.

* - *

2FA off(또는 DORMANT)면 {@link LoginFinalizer} 로 기존과 동일하게 즉시 확정한다. + *

2FA off 면 동시 접속(다른 곳 활성 세션) 여부를 확인해, 있으면 확정을 보류하고 + * {@code /login?duplicate=1} 확인 팝업으로 유도한다({@link DuplicateLoginService}). + * 없으면(또는 DORMANT) {@link LoginFinalizer} 로 기존과 동일하게 즉시 확정한다. * 실질 후처리 로직은 모두 {@link LoginFinalizer} 로 이관되어 로그인/2FA/가입자동로그인이 공유한다.

*/ @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); diff --git a/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java b/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java index 2dcb3b2..b3ce74c 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java @@ -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 토큰 저장소)이 타임아웃되면 diff --git a/src/main/java/com/eactive/apim/portal/config/PortalConfigWebDispatcherServlet.java b/src/main/java/com/eactive/apim/portal/config/PortalConfigWebDispatcherServlet.java index 4080179..503d534 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalConfigWebDispatcherServlet.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalConfigWebDispatcherServlet.java @@ -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 등)가 쿼리스트링으로 노출되는 것을 차단한다. + * + *

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/기존 설정은 그대로 유지) 누수를 막는다.

+ * + *

{@code static} 메서드로 선언해 이 설정 클래스가 조기 초기화되는 것을 피한다. + * 명시적 {@code RedirectAttributes}/flash 속성은 영향받지 않는다(기본 모델만 무시).

+ */ + @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 추가 // ------------------------------------------------------------- diff --git a/src/main/java/com/eactive/apim/portal/config/PortalProperties.java b/src/main/java/com/eactive/apim/portal/config/PortalProperties.java index f1306b5..148f79c 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalProperties.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalProperties.java @@ -25,8 +25,6 @@ public class PortalProperties { private String authVirtualCode = ""; - private boolean testAuthNoticeEnabled = false; - private Map> portalSecurity; private FileProperties file = new FileProperties(); diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 88ae6a1..9b819f8 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -40,7 +40,6 @@ gateway: portal: # auth-virtual-code: 654321 - test-auth-notice-enabled: true dev: # application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영 hot-reload-pages: true \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 6a5d06f..9980d52 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -28,9 +28,6 @@ spring: thymeleaf: cache: false -portal: - test-auth-notice-enabled: false # prod 환경: UI 안내 미표시 - app: resource-versioning: enabled: true diff --git a/src/main/resources/application-stage.yml b/src/main/resources/application-stage.yml index 79480af..511c4b5 100644 --- a/src/main/resources/application-stage.yml +++ b/src/main/resources/application-stage.yml @@ -22,7 +22,6 @@ spring: cache: false portal: - test-auth-notice-enabled: true # 검증 단계에선 사용하지 않음 # auth-virtual-code: 654321 dev: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3ce1a25..1d1d712 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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 diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css index 3f60f65..92b5451 100644 --- a/src/main/resources/static/css/main.css +++ b/src/main/resources/static/css/main.css @@ -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; diff --git a/src/main/resources/static/js/popup/custom-popups.js b/src/main/resources/static/js/popup/custom-popups.js index 535c914..4186023 100644 --- a/src/main/resources/static/js/popup/custom-popups.js +++ b/src/main/resources/static/js/popup/custom-popups.js @@ -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 () { diff --git a/src/main/resources/static/sass/main.scss b/src/main/resources/static/sass/main.scss index 0a794f8..58c13ca 100644 --- a/src/main/resources/static/sass/main.scss +++ b/src/main/resources/static/sass/main.scss @@ -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 *; diff --git a/src/main/resources/templates/views/apps/login/findIdResult.html b/src/main/resources/templates/views/apps/login/findIdResult.html index ae7aafa..b6bb66f 100644 --- a/src/main/resources/templates/views/apps/login/findIdResult.html +++ b/src/main/resources/templates/views/apps/login/findIdResult.html @@ -32,7 +32,7 @@
- -
- - -
-
@@ -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; - } - } }); }); diff --git a/src/main/resources/templates/views/apps/mypage/apiKeyModifyStep2.html b/src/main/resources/templates/views/apps/mypage/apiKeyModifyStep2.html index 834dbd9..f7b40b5 100644 --- a/src/main/resources/templates/views/apps/mypage/apiKeyModifyStep2.html +++ b/src/main/resources/templates/views/apps/mypage/apiKeyModifyStep2.html @@ -111,6 +111,38 @@
+ + + diff --git a/src/main/resources/templates/views/apps/mypage/apiKeyModifyStep3.html b/src/main/resources/templates/views/apps/mypage/apiKeyModifyStep3.html index ff7b91b..b41546b 100644 --- a/src/main/resources/templates/views/apps/mypage/apiKeyModifyStep3.html +++ b/src/main/resources/templates/views/apps/mypage/apiKeyModifyStep3.html @@ -151,7 +151,7 @@
-

앱 수정이 완료되었습니다.

+

앱 수정 신청이 완료되었습니다.

담당자 승인 후 변경 사항이 적용됩니다.
diff --git a/src/main/resources/templates/views/apps/mypage/passwordChange.html b/src/main/resources/templates/views/apps/mypage/passwordChange.html index 2cb746e..c024bc0 100644 --- a/src/main/resources/templates/views/apps/mypage/passwordChange.html +++ b/src/main/resources/templates/views/apps/mypage/passwordChange.html @@ -22,7 +22,6 @@

-
-

소중한 계정 보호를 위해 비밀번호를 변경해 주세요!

+

소중한 계정 보호를 위해 비밀번호를 변경해 주세요!
+ 비밀번호 변경이 완료되면 자동으로 로그아웃되며, 새 비밀번호로 다시 로그인해야 합니다.

diff --git a/src/main/resources/templates/views/apps/register/components/commonUserInfoForm.html b/src/main/resources/templates/views/apps/register/components/commonUserInfoForm.html index 1ebfe97..608753c 100644 --- a/src/main/resources/templates/views/apps/register/components/commonUserInfoForm.html +++ b/src/main/resources/templates/views/apps/register/components/commonUserInfoForm.html @@ -27,7 +27,10 @@
-
+ + +
@@ -48,8 +51,6 @@
- -
@@ -239,6 +240,12 @@ setEmailAuthMsg('인증번호를 발송했습니다.', false); btn.text('인증번호 재발송'); startEmailCodeTimer(); + // 테스트 환경(PTL_PROPERTY auth.test-notice.enabled)일 때만 인증번호 노출 + if (res.authNumber) { + $('#emailTestNotice').html('테스트 환경입니다. 실제 이메일이 발송되지 않으며, 인증번호는 ' + res.authNumber + ' 입니다.').show(); + } else { + $('#emailTestNotice').hide().empty(); + } } else { setEmailAuthMsg((res && res.message) || '발송에 실패했습니다.', true); } diff --git a/src/main/resources/templates/views/apps/register/components/orgUserInfoForm.html b/src/main/resources/templates/views/apps/register/components/orgUserInfoForm.html index e2195a7..1234580 100644 --- a/src/main/resources/templates/views/apps/register/components/orgUserInfoForm.html +++ b/src/main/resources/templates/views/apps/register/components/orgUserInfoForm.html @@ -45,6 +45,8 @@ +
@@ -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 = '테스트 환경입니다. 실제 이메일이 발송되지 않으며, 인증번호는 ' + res.authNumber + ' 입니다.'; + notice.style.display = ''; + } else { + notice.style.display = 'none'; + notice.innerHTML = ''; + } + } } else { setEmailAuthMsg((res && res.message) || '발송에 실패했습니다.', true); } diff --git a/src/main/resources/templates/views/apps/register/userRegisterResult.html b/src/main/resources/templates/views/apps/register/userRegisterResult.html index 6010a6c..fd2848c 100644 --- a/src/main/resources/templates/views/apps/register/userRegisterResult.html +++ b/src/main/resources/templates/views/apps/register/userRegisterResult.html @@ -24,19 +24,15 @@ -

회원가입 신청이 완료되었습니다.

+

회원가입이 완료되었습니다.

-
-

+

+

관리자의 확인 및 승인 이후
이용하실 수 있습니다.

-

- API Portal 서비스를 이용하기 위해서
- 이메일 인증을 완료해 주세요. -

diff --git a/src/main/resources/templates/views/fragment/popup/withdrawalPopup.html b/src/main/resources/templates/views/fragment/popup/withdrawalPopup.html index 59c58a4..9d40463 100644 --- a/src/main/resources/templates/views/fragment/popup/withdrawalPopup.html +++ b/src/main/resources/templates/views/fragment/popup/withdrawalPopup.html @@ -1,5 +1,6 @@ - diff --git a/src/test/java/com/eactive/apim/portal/apps/user/AccountControllerTest.java b/src/test/java/com/eactive/apim/portal/apps/user/AccountControllerTest.java index c60ffbf..c5f6a2f 100644 --- a/src/test/java/com/eactive/apim/portal/apps/user/AccountControllerTest.java +++ b/src/test/java/com/eactive/apim/portal/apps/user/AccountControllerTest.java @@ -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 = 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())