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