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 f39ff17..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 @@ -50,6 +50,8 @@ public final class StepUpProtectedPaths { 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."; @@ -68,6 +70,7 @@ public final class StepUpProtectedPaths { 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<>(); @@ -76,6 +79,7 @@ public final class StepUpProtectedPaths { 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 레벨 중 "진입 시점" 보호가 필요한 경로만. @@ -83,6 +87,8 @@ public final class StepUpProtectedPaths { // - APP_MODIFY_COMMIT 도 동일 — 다단계(step1→step2) 진행 중 중복 인증을 막기 위해 // 최종 반영 직전에만 컨트롤러가 통과권을 요구 → 제외 // - MYPAGE 는 별도 확인 페이지로 컨트롤러가 유도(PASSWORD 레벨) → 제외 + // - WITHDRAW 는 팝업(사유 입력)→2FA→제출 순서로 프론트가 유도하고 + // 컨트롤러가 커밋 직전 통과권을 요구 → 제외 Set guarded = new java.util.LinkedHashSet<>(); guarded.add(REVEAL_SECRET); guarded.add(APP_KEY_DELETE); 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/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 7dede1b..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 @@ -280,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")); @@ -290,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/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/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/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 @@
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 @@ -