merge 충돌 해결
This commit is contained in:
@@ -39,7 +39,24 @@ public class ApiController {
|
||||
if (id == null) {
|
||||
return "redirect:/apis/common";
|
||||
}
|
||||
populateDetailModel(id, model);
|
||||
model.addAttribute("activeTab", "api-info");
|
||||
return "apps/apis/mainApiDetail";
|
||||
}
|
||||
|
||||
// 테스트베드를 API 정보와 별도 URL로 분리(딥링크·북마크 가능). 미인증 사용자도 페이지 진입은
|
||||
// 허용하되, 실제 테스트베드(Swagger)는 인증 사용자에게만 렌더하고 미인증에는 로그인 안내를 노출한다.
|
||||
@GetMapping("/detail/testbed")
|
||||
public String apidetailTestbed(@RequestParam(value = "id", required = false) String id, ModelMap model) {
|
||||
if (id == null) {
|
||||
return "redirect:/apis/common";
|
||||
}
|
||||
populateDetailModel(id, model);
|
||||
model.addAttribute("activeTab", "testbed");
|
||||
return "apps/apis/mainApiDetail";
|
||||
}
|
||||
|
||||
private void populateDetailModel(String id, ModelMap model) {
|
||||
ApiSpecInfoDto api = apiService.selectDetail(id);
|
||||
if (api == null) {
|
||||
throw new NotFoundException(NOT_FOUND_MESSAGE);
|
||||
@@ -50,7 +67,7 @@ public class ApiController {
|
||||
model.addAttribute("apiSpecInfo", api);
|
||||
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
|
||||
model.addAttribute("services", searchResult.get("services"));
|
||||
return "apps/apis/mainApiDetail";
|
||||
model.addAttribute("authenticated", SecurityUtil.isAuthenticated());
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
|
||||
@@ -4,5 +4,11 @@ public interface AuthNumberService {
|
||||
|
||||
String sendRequestAuthNumber(String recipientKey, String msgType);
|
||||
|
||||
/**
|
||||
* 인증번호를 지정한 유효시간(초)으로 발송한다. 로그인/step-up 2FA 는 회원가입 기본 TTL 과
|
||||
* 다른 값을 쓸 수 있으므로 호출부에서 TTL 을 지정한다.
|
||||
*/
|
||||
String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds);
|
||||
|
||||
boolean verifyAuthNumber(String recipientKey, String authNumber);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,13 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
||||
@Override
|
||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||
public String sendRequestAuthNumber(String recipientKey, String msgType) {
|
||||
logger.info("Sending auth number to: {} via {}", recipientKey, msgType);
|
||||
return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||
public String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds) {
|
||||
logger.info("Sending auth number to: {} via {} (ttl={}s)", recipientKey, msgType, ttlSeconds);
|
||||
|
||||
validateResendTime(recipientKey);
|
||||
|
||||
@@ -55,7 +61,7 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
||||
messageSender.sendAuthMessage(recipient, authNumber, msgType);
|
||||
|
||||
storage.saveAuthNumber(recipientKey, authNumber,
|
||||
LocalDateTime.now().plusSeconds(authNumberExpirationTime));
|
||||
LocalDateTime.now().plusSeconds(ttlSeconds));
|
||||
|
||||
return authNumber;
|
||||
}
|
||||
@@ -66,17 +72,20 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
||||
logger.info("Verifying auth number for: {}", recipientKey);
|
||||
|
||||
TwoFactorAuth storedAuth = storage.getAuthNumber(recipientKey)
|
||||
.orElseThrow(() -> new AuthNumberException("인증번호가 존재하지 않습니다. 인증번호를 다시 발송해주세요."));
|
||||
.orElseThrow(() -> new AuthNumberException("인증번호가 존재하지 않습니다. 인증번호를 다시 발송해주세요.",
|
||||
AuthNumberException.Reason.NOT_FOUND));
|
||||
|
||||
if (storedAuth.getExpiresAt().isBefore(LocalDateTime.now())) {
|
||||
storage.deleteAuthNumber(recipientKey);
|
||||
throw new AuthNumberException("입력 시간이 초과되었습니다. 인증번호를 다시 발송해주세요.");
|
||||
throw new AuthNumberException("입력 시간이 초과되었습니다. 인증번호를 다시 발송해주세요.",
|
||||
AuthNumberException.Reason.EXPIRED);
|
||||
}
|
||||
|
||||
if (authNumber.equals(storedAuth.getAuthNumber())) {
|
||||
return true;
|
||||
} else {
|
||||
throw new AuthNumberException("입력된 인증번호가 올바르지 않습니다.");
|
||||
throw new AuthNumberException("입력된 인증번호가 올바르지 않습니다.",
|
||||
AuthNumberException.Reason.MISMATCH);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* step-up 2FA(민감기능 추가 인증) 진입 가드.
|
||||
*
|
||||
* <p>{@link StepUpProtectedPaths#isInterceptorGuarded(String)} 경로 진입 시, 유효한 1회용
|
||||
* 통과권이 없으면 2FA 를 요구한다.
|
||||
* <ul>
|
||||
* <li>GET(페이지 진입) → {@code /auth/2fa/challenge} 로 리다이렉트(원경로는 returnUrl 로 보존)</li>
|
||||
* <li>POST(AJAX: Secret 조회/앱 해지) → {@code 401 + {"stepUpRequired":true}} JSON</li>
|
||||
* </ul>
|
||||
* "매번 인증" 정책이므로 통과권은 {@code consumeStepUpPass} 에서 즉시 소멸한다.</p>
|
||||
*
|
||||
* <p>내 정보 변경({@code /mypage}, PASSWORD 레벨)과 비밀번호 반영({@code POST /password/change},
|
||||
* 반영 직전 2FA)은 각 컨트롤러가 직접 관장하므로 이 인터셉터 대상이 아니다.</p>
|
||||
*/
|
||||
public class StepUpAuthInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final TwoFactorProperties twoFactorProperties;
|
||||
|
||||
public StepUpAuthInterceptor(TwoFactorService twoFactorService, TwoFactorProperties twoFactorProperties) {
|
||||
this.twoFactorService = twoFactorService;
|
||||
this.twoFactorProperties = twoFactorProperties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
if (!twoFactorProperties.isStepUpEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String path = request.getServletPath();
|
||||
if (!StepUpProtectedPaths.isInterceptorGuarded(path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 지점별 스위치가 꺼져 있으면 해당 경로는 step-up 미적용
|
||||
if (!twoFactorProperties.isStepUpPointEnabled(path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session == null) {
|
||||
// 세션(=인증)이 없으면 여기서 다루지 않고 보안 계층(@Secured)에 맡긴다.
|
||||
return true;
|
||||
}
|
||||
|
||||
// 1회용 통과권 소비 시도 (매번 인증: 있으면 소멸 후 통과)
|
||||
if (twoFactorService.consumeStepUpPass(session, path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ("POST".equalsIgnoreCase(request.getMethod())) {
|
||||
// AJAX 지점(Secret 조회/앱 해지) → 프론트가 팝업을 띄우도록 신호
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write("{\"stepUpRequired\":true}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// GET 페이지 진입 → 챌린지 페이지로 유도(원경로+쿼리 보존)
|
||||
String returnUrl = path;
|
||||
String query = request.getQueryString();
|
||||
if (query != null && !query.isEmpty()) {
|
||||
returnUrl = returnUrl + "?" + query;
|
||||
}
|
||||
String encoded = URLEncoder.encode(returnUrl, StandardCharsets.UTF_8.name());
|
||||
response.sendRedirect(request.getContextPath() + "/auth/2fa/challenge?returnUrl=" + encoded);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.apps.user.facade.UserFacade;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
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 javax.servlet.http.HttpSession;
|
||||
|
||||
/**
|
||||
* 완화된 step-up(PASSWORD 레벨) 확인 페이지.
|
||||
*
|
||||
* <p>{@link StepUpProtectedPaths#isPasswordGated(String)} 경로(예: {@code /mypage})는 2FA 대신
|
||||
* <b>현재 비밀번호 재확인</b>만 요구한다. 각 컨트롤러가 통과권이 없을 때 이 페이지로 유도하고,
|
||||
* 확인 성공 시 해당 경로의 통과권을 발급한 뒤 원경로로 복귀시킨다.</p>
|
||||
*
|
||||
* <p>returnUrl 은 PASSWORD 레벨 화이트리스트로만 검증·복귀하여 open redirect 를 막는다.</p>
|
||||
*/
|
||||
@Controller
|
||||
@Secured("ROLE_ACCOUNT")
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/auth/stepup")
|
||||
public class StepUpPasswordController {
|
||||
|
||||
private final UserFacade userFacade;
|
||||
private final TwoFactorService twoFactorService;
|
||||
|
||||
@GetMapping("/password")
|
||||
public String page(@RequestParam(required = false) String returnUrl, Model model) {
|
||||
String path = pathOf(returnUrl);
|
||||
if (!StepUpProtectedPaths.isPasswordGated(path)) {
|
||||
return "redirect:/";
|
||||
}
|
||||
model.addAttribute("returnUrl", path);
|
||||
return "apps/auth/stepupPassword";
|
||||
}
|
||||
|
||||
@PostMapping("/password")
|
||||
public String verify(@RequestParam String currentPassword,
|
||||
@RequestParam(required = false) String returnUrl,
|
||||
HttpSession session, Model model) {
|
||||
String path = pathOf(returnUrl);
|
||||
if (!StepUpProtectedPaths.isPasswordGated(path)) {
|
||||
return "redirect:/";
|
||||
}
|
||||
|
||||
String loginId = SecurityUtil.getCurrentLoginId();
|
||||
if (userFacade.verifyCurrentPassword(loginId, currentPassword)) {
|
||||
// 확인 성공 → 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
|
||||
twoFactorService.grantStepUpPass(session, path);
|
||||
return "redirect:" + path;
|
||||
}
|
||||
|
||||
model.addAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
|
||||
model.addAttribute("returnUrl", path);
|
||||
return "apps/auth/stepupPassword";
|
||||
}
|
||||
|
||||
/** 쿼리스트링을 제외한 경로 부분만 추출(화이트리스트 검증용, open redirect 방지) */
|
||||
private static String pathOf(String url) {
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
int q = url.indexOf('?');
|
||||
return q >= 0 ? url.substring(0, q) : url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* step-up(민감기능 추가 인증) 대상 서블릿 경로 화이트리스트 + 지점별 프로퍼티 키 + 검증 레벨.
|
||||
*
|
||||
* <p>검증 레벨(완화 정책)</p>
|
||||
* <ul>
|
||||
* <li>{@link Level#TWO_FACTOR} — 공통 2FA 팝업(휴대폰/이메일 인증번호). 진입 인터셉터 또는
|
||||
* AJAX 401 신호로 유도. 예: Secret 조회/앱 해지/앱 정보수정.</li>
|
||||
* <li>{@link Level#PASSWORD} — 현재 비밀번호 재확인만 요구(2FA 없음). 별도 확인 페이지
|
||||
* ({@code /auth/stepup/password})로 유도. 예: 내 정보 변경({@code /mypage}).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>세 가지 관심사를 분리한다.</p>
|
||||
* <ol>
|
||||
* <li>{@link #isTwoFactorPurpose(String)} — 공통 2FA 팝업의 유효 대상(purpose) 화이트리스트.
|
||||
* open redirect / 임의 purpose 로의 2FA 발송·통과권 발급을 막는 데 쓴다.</li>
|
||||
* <li>{@link #isInterceptorGuarded(String)} — {@link StepUpAuthInterceptor} 가 진입 시점에
|
||||
* 자동 차단하는 경로. 비밀번호 변경(반영 직전 확인)·내 정보(별도 확인 페이지)는
|
||||
* 각 컨트롤러가 직접 관장하므로 여기서 제외한다.</li>
|
||||
* <li>{@link #isPasswordGated(String)} — 현재 비밀번호 재확인으로 보호하는 경로.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>지점별 활성화는 PTL_PROPERTY 키({@code two-factor.stepup.<지점>})로 개별 제어하며
|
||||
* 전체 스위치 {@code two-factor.stepup.enabled} 와 AND 로 동작한다.</p>
|
||||
*/
|
||||
public final class StepUpProtectedPaths {
|
||||
|
||||
/** step-up 검증 레벨(완화 정책) */
|
||||
public enum Level {
|
||||
/** 공통 2FA 팝업(인증번호) */
|
||||
TWO_FACTOR,
|
||||
/** 현재 비밀번호 재확인만 */
|
||||
PASSWORD
|
||||
}
|
||||
|
||||
/** Secret 키 조회 (AJAX POST) */
|
||||
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";
|
||||
/** 개인정보 변경 페이지 진입 (GET, 정확 일치) — PASSWORD 레벨 */
|
||||
public static final String MYPAGE = "/mypage";
|
||||
/** 비밀번호 변경 반영(commit, POST) — 반영 직전 2FA. 진입(GET)은 가드하지 않음 */
|
||||
public static final String PASSWORD_CHANGE = "/password/change";
|
||||
|
||||
/** PTL_PROPERTY 지점 키 접두 (전체 스위치 two-factor.stepup.enabled 와 구분) */
|
||||
private static final String KEY_PREFIX = "two-factor.stepup.";
|
||||
|
||||
/** 경로 → 지점별 프로퍼티 키. 삽입 순서 유지(LinkedHashMap) */
|
||||
private static final Map<String, String> PATH_TO_KEY;
|
||||
/** 경로 → 검증 레벨 */
|
||||
private static final Map<String, Level> PATH_TO_LEVEL;
|
||||
/** 인터셉터가 진입 시점에 자동 차단하는 경로(2FA) */
|
||||
private static final Set<String> INTERCEPTOR_GUARDED;
|
||||
|
||||
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_KEY_DELETE, KEY_PREFIX + "app-delete");
|
||||
keys.put(MYPAGE, KEY_PREFIX + "mypage");
|
||||
keys.put(PASSWORD_CHANGE, KEY_PREFIX + "password-change");
|
||||
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_KEY_DELETE, Level.TWO_FACTOR);
|
||||
levels.put(MYPAGE, Level.PASSWORD);
|
||||
levels.put(PASSWORD_CHANGE, Level.TWO_FACTOR);
|
||||
PATH_TO_LEVEL = Collections.unmodifiableMap(levels);
|
||||
|
||||
// 인터셉터 진입 자동 차단: 2FA 레벨 중 "진입 시점" 보호가 필요한 경로만.
|
||||
// - PASSWORD_CHANGE 는 반영(POST commit) 직전에 컨트롤러가 통과권을 요구 → 제외
|
||||
// - MYPAGE 는 별도 확인 페이지로 컨트롤러가 유도(PASSWORD 레벨) → 제외
|
||||
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);
|
||||
}
|
||||
|
||||
private StepUpProtectedPaths() {
|
||||
}
|
||||
|
||||
/** 공통 2FA 팝업의 유효 대상(purpose)인지 — open redirect / 임의 purpose 차단용 */
|
||||
public static boolean isTwoFactorPurpose(String servletPath) {
|
||||
return servletPath != null
|
||||
&& PATH_TO_LEVEL.get(servletPath) == Level.TWO_FACTOR;
|
||||
}
|
||||
|
||||
/** 인터셉터가 진입 시점에 자동 차단하는 경로인지 */
|
||||
public static boolean isInterceptorGuarded(String servletPath) {
|
||||
return servletPath != null && INTERCEPTOR_GUARDED.contains(servletPath);
|
||||
}
|
||||
|
||||
/** 현재 비밀번호 재확인으로 보호하는 경로인지(PASSWORD 레벨) */
|
||||
public static boolean isPasswordGated(String servletPath) {
|
||||
return servletPath != null && PATH_TO_LEVEL.get(servletPath) == Level.PASSWORD;
|
||||
}
|
||||
|
||||
/** 해당 경로의 지점별 활성화 프로퍼티 키. 대상 경로가 아니면 null */
|
||||
public static String propertyKeyOf(String servletPath) {
|
||||
return servletPath == null ? null : PATH_TO_KEY.get(servletPath);
|
||||
}
|
||||
|
||||
/** 지점별 프로퍼티 키 접두 */
|
||||
public static String keyPrefix() {
|
||||
return KEY_PREFIX;
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.portaluser.repository.TwoFactorAuthRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 만료된 2FA 인증번호(PTL_TWO_FACTOR_AUTH) 정리 스케줄러.
|
||||
*
|
||||
* <p>검증은 접근 시점 lazy 만료 검사만 하므로, 발송 후 검증 없이 방치된 레코드가 남는다.
|
||||
* 1분 주기로 만료분을 일괄 삭제한다.</p>
|
||||
*
|
||||
* <p><b>다중화(스케일아웃) 안전성:</b> 작업이 "만료된 행만" 지우는 멱등 delete 라
|
||||
* 여러 인스턴스가 동시에 실행해도 결과가 동일하고 부작용이 없다. 따라서 분산 락
|
||||
* (ShedLock 등)이 필요 없다. 동일 행을 둘이 지우려 하면 한쪽이 0건 삭제로 끝날 뿐이다.</p>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class TwoFactorCleanupScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TwoFactorCleanupScheduler.class);
|
||||
|
||||
private final TwoFactorAuthRepository twoFactorAuthRepository;
|
||||
|
||||
@Scheduled(fixedRate = 60000)
|
||||
@Transactional
|
||||
public void cleanupExpired() {
|
||||
try {
|
||||
int deleted = twoFactorAuthRepository.deleteAllByExpiresAtBefore(LocalDateTime.now());
|
||||
if (deleted > 0) {
|
||||
log.debug("만료된 2FA 인증번호 {}건 정리", deleted);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("2FA 인증번호 정리 실패", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 진행 중인 2FA 절차 상태. HTTP 세션에 단일 소스로 보관한다.
|
||||
*
|
||||
* <p>세션 클러스터링(stage/prod Redis/Ehcache) 대상이므로 {@link Serializable} 이다.
|
||||
* 여러 탭/페이지에서 동시에 2FA 가 발동되지 않도록, 발송 시 이 컨텍스트 존재 여부로
|
||||
* "진행 중" 을 판정하고 confirm 후 강제 종료(force)로만 새 절차를 시작한다.</p>
|
||||
*/
|
||||
public class TwoFactorContext implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public enum Mode {
|
||||
/** 로그인 1차 인증 통과 후 대기(pending) 상태의 2FA */
|
||||
LOGIN,
|
||||
/** 로그인 이후 민감기능 접근 시 추가 인증(step-up) */
|
||||
STEPUP
|
||||
}
|
||||
|
||||
private Mode mode;
|
||||
/** 발송 채널 (EMAIL | SMS) */
|
||||
private String channel;
|
||||
/** AuthNumberService 에 전달한 실제 수신처 문자열(이메일 소문자 / 휴대폰 digits). 검증 시 동일 값 사용 */
|
||||
private String recipient;
|
||||
/** step-up 대상 보호 경로(purpose). LOGIN 모드에서는 null */
|
||||
private String purpose;
|
||||
/** 발송 시각 */
|
||||
private LocalDateTime startedAt;
|
||||
/** 유효시간(초) */
|
||||
private int ttlSeconds;
|
||||
/** 검증 시도 횟수 */
|
||||
private int attempts;
|
||||
|
||||
public Mode getMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
public void setMode(Mode mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public String getChannel() {
|
||||
return channel;
|
||||
}
|
||||
|
||||
public void setChannel(String channel) {
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
public String getRecipient() {
|
||||
return recipient;
|
||||
}
|
||||
|
||||
public void setRecipient(String recipient) {
|
||||
this.recipient = recipient;
|
||||
}
|
||||
|
||||
public String getPurpose() {
|
||||
return purpose;
|
||||
}
|
||||
|
||||
public void setPurpose(String purpose) {
|
||||
this.purpose = purpose;
|
||||
}
|
||||
|
||||
public LocalDateTime getStartedAt() {
|
||||
return startedAt;
|
||||
}
|
||||
|
||||
public void setStartedAt(LocalDateTime startedAt) {
|
||||
this.startedAt = startedAt;
|
||||
}
|
||||
|
||||
public int getTtlSeconds() {
|
||||
return ttlSeconds;
|
||||
}
|
||||
|
||||
public void setTtlSeconds(int ttlSeconds) {
|
||||
this.ttlSeconds = ttlSeconds;
|
||||
}
|
||||
|
||||
public int getAttempts() {
|
||||
return attempts;
|
||||
}
|
||||
|
||||
public void setAttempts(int attempts) {
|
||||
this.attempts = attempts;
|
||||
}
|
||||
|
||||
public int incrementAttempts() {
|
||||
return ++this.attempts;
|
||||
}
|
||||
|
||||
/** startedAt + ttl 기준 만료 여부(세션 컨텍스트 lazy 만료 판정용) */
|
||||
public boolean isExpired(LocalDateTime now) {
|
||||
return startedAt == null || startedAt.plusSeconds(ttlSeconds).isBefore(now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorInfoResponse;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorSendResponse;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorVerifyResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
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.ResponseBody;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
|
||||
/**
|
||||
* 공통 2FA 팝업 백엔드. 로그인 pending·step-up 을 모두 처리한다.
|
||||
*
|
||||
* <p>수신처는 서버가 세션 대상 사용자로부터 결정하므로 클라이언트는 채널만 전달한다.
|
||||
* 모든 POST 는 세션 기반 CSRF(X-XSRF-TOKEN) 보호를 받는다.</p>
|
||||
*/
|
||||
@Controller
|
||||
@RequestMapping("/auth/2fa")
|
||||
@RequiredArgsConstructor
|
||||
public class TwoFactorController {
|
||||
|
||||
private final TwoFactorService twoFactorService;
|
||||
|
||||
/** 팝업 초기 정보(채널·TTL·진행중 여부) */
|
||||
@GetMapping("/info")
|
||||
@ResponseBody
|
||||
public TwoFactorInfoResponse info(@RequestParam(required = false) String purpose, HttpSession session) {
|
||||
return twoFactorService.getInfo(session, purpose);
|
||||
}
|
||||
|
||||
/** 인증번호 발송 */
|
||||
@PostMapping("/send")
|
||||
@ResponseBody
|
||||
public TwoFactorSendResponse send(@RequestParam String channel,
|
||||
@RequestParam(required = false) String purpose,
|
||||
@RequestParam(required = false, defaultValue = "false") boolean force,
|
||||
HttpSession session) {
|
||||
return twoFactorService.send(session, channel, purpose, force);
|
||||
}
|
||||
|
||||
/** 인증번호 검증 */
|
||||
@PostMapping("/verify")
|
||||
@ResponseBody
|
||||
public TwoFactorVerifyResponse verify(@RequestParam String code,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
return twoFactorService.verify(request, session, code);
|
||||
}
|
||||
|
||||
/** 팝업 닫기/타이머 만료 → 2차 인증 실패 처리 */
|
||||
@PostMapping("/cancel")
|
||||
@ResponseBody
|
||||
public void cancel(@RequestParam(required = false, defaultValue = "CANCELLED") String reason,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
twoFactorService.cancel(request, session, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* step-up GET 진입 지점용 챌린지 페이지. 인터셉터가 리다이렉트하며, 화면이 공통 팝업을 자동 오픈한다.
|
||||
* returnUrl 은 보호 경로 화이트리스트로 검증(open redirect 방지)한다.
|
||||
*/
|
||||
@GetMapping("/challenge")
|
||||
public String challenge(@RequestParam(required = false) String returnUrl, Model model) {
|
||||
// returnUrl 은 쿼리스트링을 포함할 수 있으므로 경로 부분만 화이트리스트로 검증(open redirect 방지)
|
||||
String purpose = pathOf(returnUrl);
|
||||
if (!StepUpProtectedPaths.isTwoFactorPurpose(purpose)) {
|
||||
return "redirect:/";
|
||||
}
|
||||
model.addAttribute("returnUrl", returnUrl);
|
||||
model.addAttribute("purpose", purpose);
|
||||
return "apps/auth/twoFactorChallenge";
|
||||
}
|
||||
|
||||
private static String pathOf(String url) {
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
int q = url.indexOf('?');
|
||||
return q >= 0 ? url.substring(0, q) : url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 2FA 관련 PTL_PROPERTY 접근 래퍼.
|
||||
*
|
||||
* <p>그룹 {@code Portal}, 점 구분 소문자 키 관례({@code session.timeout.minutes},
|
||||
* {@code org.hard-delete.enabled} 등)를 따른다.
|
||||
* {@link PortalPropertyService#getOrCreateProperty} 는 최초 접근 시 기본값으로 DB row 를
|
||||
* 생성(그룹 존재 시)하므로 별도 초기 데이터가 필요 없다. 캐시가 없어 매 호출 DB 조회지만
|
||||
* 2FA 진입 경로가 제한적이라 허용 범위다.</p>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class TwoFactorProperties {
|
||||
|
||||
public static final String GROUP = "Portal";
|
||||
|
||||
public static final String KEY_LOGIN_ENABLED = "two-factor.login.enabled";
|
||||
public static final String KEY_TTL_SECONDS = "two-factor.ttl.seconds";
|
||||
public static final String KEY_ATTEMPT_LIMIT = "two-factor.attempt.limit";
|
||||
public static final String KEY_TEST_NOTICE_ENABLED = "two-factor.test-notice.enabled";
|
||||
public static final String KEY_STEPUP_ENABLED = "two-factor.stepup.enabled";
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
/** 로그인 2FA 활성화 여부 */
|
||||
public boolean isLoginEnabled() {
|
||||
return parseBool(resolve(KEY_LOGIN_ENABLED, "false", "로그인 2차 인증 활성화 여부 (true/false)"));
|
||||
}
|
||||
|
||||
/** step-up(민감기능) 2FA 전체 활성화 여부(마스터 스위치) */
|
||||
public boolean isStepUpEnabled() {
|
||||
return parseBool(resolve(KEY_STEPUP_ENABLED, "false", "민감기능 추가 인증(step-up) 전체 활성화 여부 (true/false)"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 보호 경로에 step-up 2FA 를 적용할지 여부(지점별 스위치).
|
||||
* 전체 스위치({@link #isStepUpEnabled()})가 켜진 상태에서 지점별로 개별 on/off 한다.
|
||||
* 지점 프로퍼티({@code two-factor.stepup.<지점>})의 기본값은 true(전체 스위치를 켜면 기본 전 지점 적용).
|
||||
*
|
||||
* @param servletPath 보호 경로. 매핑 키가 없으면(비보호 경로) false
|
||||
*/
|
||||
public boolean isStepUpPointEnabled(String servletPath) {
|
||||
String key = StepUpProtectedPaths.propertyKeyOf(servletPath);
|
||||
if (key == null) {
|
||||
return false;
|
||||
}
|
||||
return parseBool(resolve(key, "true", "step-up 2FA 지점 적용 여부 (true/false): " + servletPath));
|
||||
}
|
||||
|
||||
/** 2FA 인증번호 유효시간(초). 기본 180초(3분) */
|
||||
public int getTtlSeconds() {
|
||||
return parseInt(resolve(KEY_TTL_SECONDS, "180", "2차 인증번호 유효시간(초)"), 180);
|
||||
}
|
||||
|
||||
/** 인증번호 검증 시도 한도. 기본 5회 */
|
||||
public int getAttemptLimit() {
|
||||
return parseInt(resolve(KEY_ATTEMPT_LIMIT, "5", "2차 인증번호 검증 시도 한도"), 5);
|
||||
}
|
||||
|
||||
/** 팝업에 테스트용 인증번호를 노출할지 여부(개발/테스트 전용) */
|
||||
public boolean isTestNoticeEnabled() {
|
||||
return parseBool(resolve(KEY_TEST_NOTICE_ENABLED, "false", "2차 인증 팝업에 테스트용 인증번호 표시 여부 (true/false)"));
|
||||
}
|
||||
|
||||
private String resolve(String key, String defaultValue, String description) {
|
||||
return portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
|
||||
}
|
||||
|
||||
/**
|
||||
* boolean PTL_PROPERTY 값 파싱.
|
||||
* DB 관례에 맞춰 <b>true/false</b> 문자열을 사용한다(예: {@code org.hard-delete.enabled=true}).
|
||||
* "true"(대소문자 무시)만 참으로 본다. 그 외(false/공백/null 등)는 모두 거짓.
|
||||
*/
|
||||
private static boolean parseBool(String value) {
|
||||
return value != null && "true".equalsIgnoreCase(value.trim());
|
||||
}
|
||||
|
||||
private static int parseInt(String value, int fallback) {
|
||||
try {
|
||||
return Integer.parseInt(value.trim());
|
||||
} catch (Exception e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.apps.auth.service.AuthNumberService;
|
||||
import com.eactive.apim.portal.apps.auth.service.AuthNumberStorage;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorChannel;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorInfoResponse;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorSendResponse;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorVerifyResponse;
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginFailureReason;
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
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 com.eactive.apim.portal.portaluser.service.AuthNumberException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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 org.springframework.util.StringUtils;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 2FA(2차 인증/추가 인증) 공통 서비스. 로그인 pending 인증과 step-up(민감기능) 인증을 모두 처리한다.
|
||||
*
|
||||
* <p>핵심 원칙:
|
||||
* <ul>
|
||||
* <li>수신처는 서버가 세션의 대상 사용자로부터 DB 기준으로 결정한다(클라이언트는 채널만 선택).</li>
|
||||
* <li>진행 상태는 세션 {@link TwoFactorContext} 단일 소스로 관리한다.</li>
|
||||
* <li>다른 플로우가 진행 중이면 발송을 막고(inProgress), confirm 후 force 로만 강제 종료·재시작한다.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
public class TwoFactorService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TwoFactorService.class);
|
||||
|
||||
// === 세션 attribute 키 ===
|
||||
/** 로그인 1차 인증 통과 후 대기 중인 사용자 id (존재 시 LOGIN 모드) */
|
||||
public static final String ATTR_PENDING_USER_ID = "TFA_PENDING_USER_ID";
|
||||
/** 대기 중 사용자 loginId (감사/세션 표기) */
|
||||
public static final String ATTR_PENDING_LOGIN_ID = "TFA_PENDING_LOGIN_ID";
|
||||
/** 진행 중 2FA 컨텍스트 */
|
||||
public static final String ATTR_CONTEXT = "TFA_CONTEXT";
|
||||
/** step-up 1회용 통과권 - 대상 경로 */
|
||||
public static final String ATTR_STEPUP_PASS_PATH = "TFA_STEPUP_PASS_PATH";
|
||||
/** step-up 1회용 통과권 - 발급 시각 */
|
||||
public static final String ATTR_STEPUP_PASS_AT = "TFA_STEPUP_PASS_AT";
|
||||
|
||||
/** step-up 통과권 유효시간(초). 인증 성공 후 대상 페이지 진입까지의 이동 여유분 */
|
||||
public static final int STEPUP_PASS_TTL_SECONDS = 120;
|
||||
/** 재발송/채널전환 통합 최소 간격(초) */
|
||||
private static final int RESEND_THROTTLE_SECONDS = 30;
|
||||
|
||||
private final TwoFactorProperties properties;
|
||||
private final AuthNumberService authNumberService;
|
||||
private final AuthNumberStorage authNumberStorage;
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
private final PortalUserLogService userLogService;
|
||||
private final LoginFinalizer loginFinalizer;
|
||||
|
||||
// =========================================================================
|
||||
// INFO
|
||||
// =========================================================================
|
||||
|
||||
public TwoFactorInfoResponse getInfo(HttpSession session, String purpose) {
|
||||
TwoFactorInfoResponse res = new TwoFactorInfoResponse();
|
||||
|
||||
TwoFactorContext.Mode mode = resolveMode(session);
|
||||
if (mode == null) {
|
||||
res.setAvailable(false);
|
||||
return res;
|
||||
}
|
||||
PortalUser user = resolveTargetUser(session, mode);
|
||||
if (user == null) {
|
||||
res.setAvailable(false);
|
||||
return res;
|
||||
}
|
||||
|
||||
res.setAvailable(true);
|
||||
res.setMode(mode.name());
|
||||
res.setChannels(buildChannels(user));
|
||||
res.setTtlSeconds(properties.getTtlSeconds());
|
||||
res.setTestNoticeEnabled(properties.isTestNoticeEnabled());
|
||||
|
||||
TwoFactorContext ctx = getActiveContext(session);
|
||||
if (ctx != null && !isSameFlow(ctx, mode, purpose)) {
|
||||
res.setInProgress(true);
|
||||
res.setMessage("진행 중인 다른 인증 절차가 있습니다.");
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SEND
|
||||
// =========================================================================
|
||||
|
||||
public TwoFactorSendResponse send(HttpSession session, String channel, String purpose, boolean force) {
|
||||
TwoFactorSendResponse res = new TwoFactorSendResponse();
|
||||
|
||||
TwoFactorContext.Mode mode = resolveMode(session);
|
||||
if (mode == null) {
|
||||
res.setValid(false);
|
||||
res.setMessage("인증 대상 정보가 없습니다. 다시 시도해주세요.");
|
||||
return res;
|
||||
}
|
||||
if (mode == TwoFactorContext.Mode.STEPUP && !StepUpProtectedPaths.isTwoFactorPurpose(purpose)) {
|
||||
res.setValid(false);
|
||||
res.setMessage("허용되지 않은 요청입니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
PortalUser user = resolveTargetUser(session, mode);
|
||||
if (user == null) {
|
||||
res.setValid(false);
|
||||
res.setMessage("인증 대상 사용자를 찾을 수 없습니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
String normalizedChannel = channel == null ? "" : channel.trim().toUpperCase();
|
||||
String recipient = resolveRecipient(user, normalizedChannel);
|
||||
if (recipient == null) {
|
||||
res.setValid(false);
|
||||
res.setMessage("선택한 방법으로 인증할 수 있는 정보가 없습니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
// 진행 중 컨텍스트 처리
|
||||
TwoFactorContext ctx = getActiveContext(session);
|
||||
if (ctx != null) {
|
||||
boolean sameFlow = isSameFlow(ctx, mode, purpose);
|
||||
if (!sameFlow) {
|
||||
if (!force) {
|
||||
res.setValid(false);
|
||||
res.setInProgress(true);
|
||||
res.setMessage("진행 중인 다른 인증 절차가 있습니다. 강제 종료 후 진행하시겠습니까?");
|
||||
return res;
|
||||
}
|
||||
discardContext(session, ctx); // 강제 종료(감사 기록 포함)
|
||||
} else if (ctx.getStartedAt() != null
|
||||
&& ctx.getStartedAt().plusSeconds(RESEND_THROTTLE_SECONDS).isAfter(LocalDateTime.now())) {
|
||||
res.setValid(false);
|
||||
res.setMessage("잠시 후에 다시 시도해 주세요.");
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
int ttl = properties.getTtlSeconds();
|
||||
String authNumber;
|
||||
try {
|
||||
authNumber = authNumberService.sendRequestAuthNumber(recipient,
|
||||
"SMS".equals(normalizedChannel) ? "SMS" : "EMAIL", ttl);
|
||||
} catch (AuthNumberException e) {
|
||||
res.setValid(false);
|
||||
res.setMessage(e.getMessage());
|
||||
return res;
|
||||
}
|
||||
|
||||
TwoFactorContext newCtx = new TwoFactorContext();
|
||||
newCtx.setMode(mode);
|
||||
newCtx.setChannel(normalizedChannel);
|
||||
newCtx.setRecipient(recipient);
|
||||
newCtx.setPurpose(mode == TwoFactorContext.Mode.STEPUP ? purpose : null);
|
||||
newCtx.setStartedAt(LocalDateTime.now());
|
||||
newCtx.setTtlSeconds(ttl);
|
||||
newCtx.setAttempts(0);
|
||||
session.setAttribute(ATTR_CONTEXT, newCtx);
|
||||
|
||||
res.setValid(true);
|
||||
res.setMessage("인증번호를 발송하였습니다.");
|
||||
res.setTtlSeconds(ttl);
|
||||
if (properties.isTestNoticeEnabled()) {
|
||||
res.setTestAuthNumber(authNumber);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// VERIFY
|
||||
// =========================================================================
|
||||
|
||||
public TwoFactorVerifyResponse verify(HttpServletRequest request, HttpSession session, String code) {
|
||||
TwoFactorVerifyResponse res = new TwoFactorVerifyResponse();
|
||||
|
||||
TwoFactorContext ctx = getActiveContext(session);
|
||||
if (ctx == null) {
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage("인증 시간이 만료되었습니다. 처음부터 다시 진행해주세요.");
|
||||
return res;
|
||||
}
|
||||
|
||||
if (ctx.isExpired(LocalDateTime.now())) {
|
||||
terminateWithFailure(session, ctx, LoginFailureReason.TWO_FACTOR_TIMEOUT, request);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage("입력 시간이 초과되었습니다. 처음부터 다시 진행해주세요.");
|
||||
return res;
|
||||
}
|
||||
|
||||
int attempts = ctx.incrementAttempts();
|
||||
int limit = properties.getAttemptLimit();
|
||||
|
||||
try {
|
||||
authNumberService.verifyAuthNumber(ctx.getRecipient(), code);
|
||||
} catch (AuthNumberException e) {
|
||||
AuthNumberException.Reason reason = e.getReason();
|
||||
if (reason == AuthNumberException.Reason.EXPIRED || reason == AuthNumberException.Reason.NOT_FOUND) {
|
||||
terminateWithFailure(session, ctx, LoginFailureReason.TWO_FACTOR_TIMEOUT, request);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage("입력 시간이 초과되었습니다. 처음부터 다시 진행해주세요.");
|
||||
return res;
|
||||
}
|
||||
// 코드 불일치
|
||||
if (attempts >= limit) {
|
||||
terminateWithFailure(session, ctx, LoginFailureReason.TWO_FACTOR_ATTEMPT_EXCEEDED, request);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage("인증 시도 횟수를 초과했습니다. 처음부터 다시 진행해주세요.");
|
||||
return res;
|
||||
}
|
||||
session.setAttribute(ATTR_CONTEXT, ctx); // attempts 갱신 반영
|
||||
res.setValid(false);
|
||||
res.setRemainingAttempts(limit - attempts);
|
||||
res.setMessage("인증번호가 일치하지 않습니다. (남은 횟수 " + (limit - attempts) + "회)");
|
||||
return res;
|
||||
}
|
||||
|
||||
// 검증 성공 — 인증번호 즉시 소비(재사용 방지, 2FA 한정)
|
||||
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
|
||||
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
return completeLogin(request, session, res);
|
||||
}
|
||||
|
||||
// STEPUP — 1회용 통과권 발급
|
||||
issueStepUpPass(session, ctx.getPurpose());
|
||||
res.setValid(true);
|
||||
res.setMessage("인증이 완료되었습니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
private TwoFactorVerifyResponse completeLogin(HttpServletRequest request, HttpSession session,
|
||||
TwoFactorVerifyResponse res) {
|
||||
String userId = (String) session.getAttribute(ATTR_PENDING_USER_ID);
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
|
||||
PortalUser user = userId != null ? portalUserRepository.findById(userId).orElse(null) : null;
|
||||
if (user == null) {
|
||||
clearPending(session);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage("로그인 정보를 찾을 수 없습니다. 다시 로그인해주세요.");
|
||||
return res;
|
||||
}
|
||||
|
||||
// 1차 인증~2FA 사이 상태 변경 방어(잠금/차단/승인 취소)
|
||||
String stateError = revalidateLoginState(user);
|
||||
if (stateError != null) {
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(),
|
||||
LoginFailureReason.ACCOUNT_DISABLED);
|
||||
clearPending(session);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage(stateError);
|
||||
return res;
|
||||
}
|
||||
|
||||
// 프로그래매틱 인증 확정 (요청 종료 시 SecurityContextPersistenceFilter 가 세션에 저장)
|
||||
PortalAuthenticatedUser authUser = portalUserAuthService.buildAuthenticatedUser(user);
|
||||
UsernamePasswordAuthenticationToken token =
|
||||
new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());
|
||||
token.setDetails(authUser);
|
||||
SecurityContextHolder.getContext().setAuthentication(token);
|
||||
|
||||
String redirect = loginFinalizer.finalizeLogin(user, loginId, request, LoginType.TWO_FACTOR);
|
||||
clearPending(session);
|
||||
|
||||
res.setValid(true);
|
||||
res.setRedirect(redirect);
|
||||
res.setMessage("인증이 완료되었습니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// CANCEL (팝업 닫기 / 타이머 만료)
|
||||
// =========================================================================
|
||||
|
||||
public void cancel(HttpServletRequest request, HttpSession session, String reason) {
|
||||
TwoFactorContext ctx = getActiveContext(session);
|
||||
boolean timeout = "TIMEOUT".equalsIgnoreCase(reason);
|
||||
LoginFailureReason failureReason = timeout
|
||||
? LoginFailureReason.TWO_FACTOR_TIMEOUT : LoginFailureReason.TWO_FACTOR_CANCELLED;
|
||||
|
||||
if (ctx != null && ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), failureReason);
|
||||
}
|
||||
if (ctx != null && ctx.getRecipient() != null) {
|
||||
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||
}
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
|
||||
// 로그인 2FA 취소는 로그인 자체를 포기(익명 유지) → pending 제거
|
||||
if (ctx == null || ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
clearPending(session);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// LOGIN pending 진입 (SuccessHandler 에서 호출)
|
||||
// =========================================================================
|
||||
|
||||
/** 로그인 1차 인증 통과 사용자를 2FA 대기 상태로 세팅한다. (SecurityContext 클리어는 호출부 책임) */
|
||||
public void beginLoginChallenge(HttpSession session, PortalUser user) {
|
||||
session.setAttribute(ATTR_PENDING_USER_ID, user.getId());
|
||||
session.setAttribute(ATTR_PENDING_LOGIN_ID, user.getLoginId());
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
}
|
||||
|
||||
public boolean hasPendingLogin(HttpSession session) {
|
||||
return session != null && session.getAttribute(ATTR_PENDING_USER_ID) != null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// STEP-UP 통과권
|
||||
// =========================================================================
|
||||
|
||||
private void issueStepUpPass(HttpSession session, String path) {
|
||||
session.setAttribute(ATTR_STEPUP_PASS_PATH, path);
|
||||
session.setAttribute(ATTR_STEPUP_PASS_AT, LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* 보호 경로 수정 저장 직후 원경로로 되돌아가는 즉시 왕복(예: {@code /mypage} 수정 →
|
||||
* {@code redirect:/mypage})에서 중복 step-up 을 막기 위해 통과권을 재발급한다.
|
||||
*
|
||||
* <p>경로 고정 + TTL({@link #STEPUP_PASS_TTL_SECONDS}s) 로 <b>1회 왕복만</b> 커버하며,
|
||||
* 이후 새로 {@code /mypage} 에 진입하면 정상적으로 다시 인증을 요구한다("매번 인증" 유지).</p>
|
||||
*/
|
||||
public void grantStepUpPass(HttpSession session, String path) {
|
||||
if (session != null && path != null) {
|
||||
issueStepUpPass(session, path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 경로에 대한 유효한 1회용 통과권이 있으면 소비(제거)하고 true 를 반환한다.
|
||||
* (매번 인증 정책 — 통과권은 즉시 소멸)
|
||||
*/
|
||||
public boolean consumeStepUpPass(HttpSession session, String servletPath) {
|
||||
Object passPath = session.getAttribute(ATTR_STEPUP_PASS_PATH);
|
||||
Object passAt = session.getAttribute(ATTR_STEPUP_PASS_AT);
|
||||
if (!(passPath instanceof String) || !(passAt instanceof LocalDateTime)) {
|
||||
return false;
|
||||
}
|
||||
boolean valid = passPath.equals(servletPath)
|
||||
&& ((LocalDateTime) passAt).plusSeconds(STEPUP_PASS_TTL_SECONDS).isAfter(LocalDateTime.now());
|
||||
// 매번 인증: 일치/불일치 무관하게 통과권은 이번 판정에서 소멸시킨다.
|
||||
session.removeAttribute(ATTR_STEPUP_PASS_PATH);
|
||||
session.removeAttribute(ATTR_STEPUP_PASS_AT);
|
||||
return valid;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 내부 helper
|
||||
// =========================================================================
|
||||
|
||||
private TwoFactorContext.Mode resolveMode(HttpSession session) {
|
||||
if (session.getAttribute(ATTR_PENDING_USER_ID) != null) {
|
||||
return TwoFactorContext.Mode.LOGIN;
|
||||
}
|
||||
if (SecurityUtil.isAuthenticated()) {
|
||||
return TwoFactorContext.Mode.STEPUP;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private PortalUser resolveTargetUser(HttpSession session, TwoFactorContext.Mode mode) {
|
||||
if (mode == TwoFactorContext.Mode.LOGIN) {
|
||||
String userId = (String) session.getAttribute(ATTR_PENDING_USER_ID);
|
||||
return userId != null ? portalUserRepository.findById(userId).orElse(null) : null;
|
||||
}
|
||||
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||
if (current == null) {
|
||||
return null;
|
||||
}
|
||||
// 세션 로드 이후 연락처 변경 반영을 위해 DB 재조회
|
||||
return portalUserRepository.findById(current.getId()).orElse(null);
|
||||
}
|
||||
|
||||
private List<TwoFactorChannel> buildChannels(PortalUser user) {
|
||||
List<TwoFactorChannel> channels = new ArrayList<>();
|
||||
if (StringUtils.hasText(user.getEmailAddr())) {
|
||||
channels.add(new TwoFactorChannel("EMAIL", StringMaskingUtil.maskEmail(user.getEmailAddr())));
|
||||
}
|
||||
if (StringUtils.hasText(user.getMobileNumber())) {
|
||||
channels.add(new TwoFactorChannel("SMS", StringMaskingUtil.maskMobileNumber(user.getMobileNumber())));
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
|
||||
private String resolveRecipient(PortalUser user, String channel) {
|
||||
if ("EMAIL".equals(channel)) {
|
||||
return StringUtils.hasText(user.getEmailAddr()) ? user.getEmailAddr() : null;
|
||||
}
|
||||
if ("SMS".equals(channel)) {
|
||||
return StringUtils.hasText(user.getMobileNumber())
|
||||
? PhoneNumberUtil.digitsOnly(user.getMobileNumber()) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private TwoFactorContext getActiveContext(HttpSession session) {
|
||||
Object ctx = session.getAttribute(ATTR_CONTEXT);
|
||||
if (!(ctx instanceof TwoFactorContext)) {
|
||||
return null;
|
||||
}
|
||||
TwoFactorContext context = (TwoFactorContext) ctx;
|
||||
if (context.isExpired(LocalDateTime.now())) {
|
||||
// 만료 컨텍스트는 정리(감사는 verify/cancel 경로에서 처리)
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
if (context.getRecipient() != null) {
|
||||
authNumberStorage.deleteAuthNumber(context.getRecipient());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
private boolean isSameFlow(TwoFactorContext ctx, TwoFactorContext.Mode mode, String purpose) {
|
||||
return ctx.getMode() == mode && Objects.equals(ctx.getPurpose(),
|
||||
mode == TwoFactorContext.Mode.STEPUP ? purpose : null);
|
||||
}
|
||||
|
||||
/** 강제 종료: 인증번호 삭제 + (로그인 컨텍스트면) 취소 감사 기록 */
|
||||
private void discardContext(HttpSession session, TwoFactorContext ctx) {
|
||||
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
userLogService.logFailure(loginId, "-", session.getId(), LoginFailureReason.TWO_FACTOR_CANCELLED);
|
||||
}
|
||||
if (ctx.getRecipient() != null) {
|
||||
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||
}
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
}
|
||||
|
||||
/** 검증 실패로 절차 종료: 인증번호 삭제 + 감사 + 컨텍스트/pending 정리 */
|
||||
private void terminateWithFailure(HttpSession session, TwoFactorContext ctx,
|
||||
LoginFailureReason reason, HttpServletRequest request) {
|
||||
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), reason);
|
||||
clearPending(session);
|
||||
}
|
||||
if (ctx.getRecipient() != null) {
|
||||
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||
}
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
}
|
||||
|
||||
private void clearPending(HttpSession session) {
|
||||
session.removeAttribute(ATTR_PENDING_USER_ID);
|
||||
session.removeAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
}
|
||||
|
||||
/** 1차 인증~2FA 사이 계정 상태 재검증. 문제 있으면 사용자 안내 메시지 반환, 정상이면 null */
|
||||
private String revalidateLoginState(PortalUser user) {
|
||||
if ("Y".equalsIgnoreCase(user.getAccountLockYn())) {
|
||||
return "계정이 잠겼습니다. 비밀번호 초기화 또는 관리자에게 문의하세요.";
|
||||
}
|
||||
if (PortalUserEnums.UserStatus.ADMINBLOCK.equals(user.getUserStatus())) {
|
||||
return "법인 관리자에 의해 비활성화된 계정입니다.";
|
||||
}
|
||||
if (PortalUserEnums.ApprovalStatus.PENDING.equals(user.getApprovalStatus())) {
|
||||
return "사용자 승인 대기중입니다.";
|
||||
}
|
||||
if (user.getPortalOrg() != null
|
||||
&& !PortalOrgEnums.ApprovalStatus.COMPLETED.equals(user.getPortalOrg().getApprovalStatus())) {
|
||||
return "로그인할 수 없습니다. 관리자에게 문의하세요. (법인 승인대기중)";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
/** 2FA 발송 가능 채널 1건. masked 는 화면 표기용 마스킹 수신처. */
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class TwoFactorChannel {
|
||||
/** EMAIL | SMS */
|
||||
private String type;
|
||||
/** 마스킹된 수신처 (예: te**@ex**.com, 010-12**-34**) */
|
||||
private String masked;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** GET /auth/2fa/info 응답. 팝업 초기 렌더용. */
|
||||
@Data
|
||||
public class TwoFactorInfoResponse {
|
||||
/** 컨텍스트 유효 여부(로그인 pending 또는 인증 사용자). false 면 팝업 진입 불가 */
|
||||
private boolean available;
|
||||
/** LOGIN | STEPUP */
|
||||
private String mode;
|
||||
/** 발송 가능 채널(휴대폰 없으면 이메일만) */
|
||||
private List<TwoFactorChannel> channels;
|
||||
/** 인증번호 유효시간(초) — 타이머 초기값 */
|
||||
private int ttlSeconds;
|
||||
/** 테스트용 인증번호 노출 여부 */
|
||||
private boolean testNoticeEnabled;
|
||||
/** 이미 진행 중인 절차 존재 여부(다른 탭/페이지) */
|
||||
private boolean inProgress;
|
||||
/** 진행 중인 절차의 안내 메시지(있으면) */
|
||||
private String message;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** POST /auth/2fa/send 응답. */
|
||||
@Data
|
||||
public class TwoFactorSendResponse {
|
||||
private boolean valid;
|
||||
private String message;
|
||||
/** 타이머 유효시간(초) */
|
||||
private int ttlSeconds;
|
||||
/** 테스트용 인증번호(테스트 노출 활성 시에만 채워짐) */
|
||||
private String testAuthNumber;
|
||||
/** 이미 진행 중인 절차가 있어 발송을 막은 경우 true (confirm 후 force 재요청 유도) */
|
||||
private boolean inProgress;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** POST /auth/2fa/verify 응답. */
|
||||
@Data
|
||||
public class TwoFactorVerifyResponse {
|
||||
private boolean valid;
|
||||
private String message;
|
||||
/** LOGIN 모드 성공 시 이동 대상 URL */
|
||||
private String redirect;
|
||||
/** 실패 시 남은 시도 횟수 */
|
||||
private int remainingAttempts;
|
||||
/** 시도 초과/타임아웃 등으로 절차가 강제 종료되어 재시작이 필요한 경우 true */
|
||||
private boolean terminated;
|
||||
}
|
||||
+1
-1
@@ -30,7 +30,7 @@ public class PartnershipApplicationController {
|
||||
@GetMapping
|
||||
public String newPartnershipApplicationForm(Model model, HttpServletRequest request) {
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
return "redirect:/login?redirect=/partnership";
|
||||
return "redirect:/login?reason=auth&redirect=/partnership";
|
||||
}
|
||||
|
||||
String referer = request.getHeader("Referer");
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.eactive.apim.portal.apps.login.constants;
|
||||
|
||||
/**
|
||||
* 로그인 실패 사유 코드. PTL_USER_LOG.FAILURE_REASON 에 문자열(name())로 저장된다.
|
||||
*/
|
||||
public enum LoginFailureReason {
|
||||
|
||||
/** 아이디(이메일) 미존재 */
|
||||
ID_NOT_FOUND,
|
||||
/** 비밀번호 불일치 */
|
||||
PASSWORD_MISMATCH,
|
||||
/** 계정 잠금(5회 실패 등) */
|
||||
ACCOUNT_LOCKED,
|
||||
/** 비활성 계정(승인 대기/관리자 차단/법인 미승인) */
|
||||
ACCOUNT_DISABLED,
|
||||
/** 세션 인증 오류(중복 로그인 등) */
|
||||
SESSION_AUTH,
|
||||
/** 2차 인증 - 인증번호 유효시간 초과 */
|
||||
TWO_FACTOR_TIMEOUT,
|
||||
/** 2차 인증 - 인증번호 불일치 */
|
||||
TWO_FACTOR_CODE_MISMATCH,
|
||||
/** 2차 인증 - 사용자가 팝업을 닫아 취소 */
|
||||
TWO_FACTOR_CANCELLED,
|
||||
/** 2차 인증 - 시도 횟수 초과 */
|
||||
TWO_FACTOR_ATTEMPT_EXCEEDED,
|
||||
/** 분류 불가 */
|
||||
UNKNOWN
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.eactive.apim.portal.apps.login.constants;
|
||||
|
||||
/**
|
||||
* 로그인 유형 코드. PTL_USER_LOG.LOGIN_TYPE 에 문자열(name())로 저장된다.
|
||||
*/
|
||||
public enum LoginType {
|
||||
|
||||
/** 일반 로그인 (2FA 미적용) */
|
||||
NORMAL,
|
||||
/** 2차 인증을 통과한 로그인 */
|
||||
TWO_FACTOR,
|
||||
/** 회원가입 직후 자동 로그인 (2FA 미적용) */
|
||||
SIGNUP_AUTO
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.eactive.apim.portal.apps.login.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
|
||||
import com.eactive.apim.portal.common.exception.PortalRedirectException;
|
||||
import com.eactive.apim.portal.common.pagerouter.PageHandler;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
@@ -22,6 +23,11 @@ import static com.eactive.apim.portal.apps.login.constants.LoginConstants.LOGIN_
|
||||
@Component("LoginHandler")
|
||||
public class LoginHandler implements PageHandler {
|
||||
|
||||
private final TwoFactorService twoFactorService;
|
||||
|
||||
public LoginHandler(TwoFactorService twoFactorService) {
|
||||
this.twoFactorService = twoFactorService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 화면으로 들어간다
|
||||
@@ -47,6 +53,10 @@ public class LoginHandler implements PageHandler {
|
||||
session.removeAttribute("loginId");
|
||||
}
|
||||
|
||||
// 로그인 2FA 대기 상태면(1차 인증 통과 후) 추가 인증 팝업 자동 오픈 플래그를 내려준다.
|
||||
// pending 중에는 아직 익명이므로 아래 인증자 리다이렉트에 걸리지 않는다.
|
||||
model.addAttribute("twoFactorPending", twoFactorService.hasPendingLogin(session));
|
||||
|
||||
// 이미 인증된 사용자인지 확인
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null && !"anonymousUser".equalsIgnoreCase(authentication.getPrincipal().toString())) {
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package com.eactive.apim.portal.apps.login.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import com.eactive.apim.portal.config.PasswordChangeEnforcementInterceptor;
|
||||
import com.eactive.apim.portal.config.PasswordEnforcementPolicy;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
|
||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.template.entity.MessageRequest;
|
||||
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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.Optional;
|
||||
|
||||
/**
|
||||
* 로그인 최종 확정(finalize) 처리를 공통화한다.
|
||||
*
|
||||
* <p>기존 {@code PortalAuthenticationSuccessHandler} 에 인라인되어 있던 후처리
|
||||
* (실패카운트 리셋, 감사 성공 기록, 후속 유도 세션 플래그, 초대 확인, 중복로그인 정리,
|
||||
* 물리 세션 타임아웃, 최종 이동 URL 결정)를 여기로 추출했다.</p>
|
||||
*
|
||||
* <p>세 경로가 이 로직을 공유한다:
|
||||
* <ul>
|
||||
* <li>일반 로그인 — 2FA off 시 SuccessHandler 가 직접 호출({@link LoginType#NORMAL})</li>
|
||||
* <li>로그인 2FA 통과 — TwoFactorService 가 호출({@link LoginType#TWO_FACTOR})</li>
|
||||
* <li>회원가입 자동 로그인 — 가입 컨트롤러가 호출({@link LoginType#SIGNUP_AUTO})</li>
|
||||
* </ul>
|
||||
* 최종 이동 URL 을 반환하며, 리다이렉트(HTTP 302)는 호출부 책임이다.</p>
|
||||
*/
|
||||
@Service
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
public class LoginFinalizer {
|
||||
|
||||
private static final Logger sessionLogger = LoggerFactory.getLogger("eapim.portal.session");
|
||||
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
|
||||
|
||||
/**
|
||||
* 미인증 상태로 보호 페이지 접근 시 저장해 둔 원래 요청 경로(로그인+2FA 완료 후 복귀 대상).
|
||||
* {@code PortalGlobalExceptionHandler} 가 저장하고 여기서 소비(1회용)한다.
|
||||
*/
|
||||
public static final String SESSION_POST_LOGIN_REDIRECT = "postLoginRedirect";
|
||||
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalProperties portalProperties;
|
||||
private final PortalUserLogService userLogService;
|
||||
private final UserPasswordHistoryRepository passwordHistoryRepository;
|
||||
private final MessageRequestRepository messageRequestRepository;
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final PortalOrgRepository portalOrgRepository;
|
||||
private final UserSessionService userSessionService;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
/**
|
||||
* 로그인 확정 후처리를 수행하고 최종 이동 URL 을 반환한다.
|
||||
*
|
||||
* @param user 인증된 사용자
|
||||
* @param rawUsername 감사/세션 표기에 쓸 사용자 식별자(로그인 폼 입력 원본 또는 loginId)
|
||||
* @param request 현재 요청(IP/헤더/세션)
|
||||
* @param loginType 로그인 유형(감사 기록용)
|
||||
* @return 리다이렉트 대상 URL (contextPath 포함)
|
||||
*/
|
||||
public String finalizeLogin(PortalUser user, String rawUsername, HttpServletRequest request, LoginType loginType) {
|
||||
String normalizedUsername = rawUsername != null ? rawUsername.toLowerCase() : null;
|
||||
|
||||
user.setLoginFailureCount(0);
|
||||
portalUserRepository.save(user);
|
||||
|
||||
String ip = request.getRemoteAddr();
|
||||
String sessionId = request.getSession().getId();
|
||||
userLogService.logSuccess(rawUsername, ip, sessionId, loginType);
|
||||
|
||||
String contextPath = request.getContextPath();
|
||||
HttpSession session = request.getSession();
|
||||
|
||||
applyPostLoginState(user, session, rawUsername, contextPath);
|
||||
|
||||
// 초대 코드 확인 - ROLE_USER만 (메인 페이지에서 팝업으로 표시)
|
||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
|
||||
Optional<UserInvitation> pendingInvitation =
|
||||
userInvitationRepository.findFirstByInvitationMobileAndStatus(
|
||||
PhoneNumberUtil.normalize(user.getMobileNumber()), InvitationStatus.PENDING);
|
||||
|
||||
if (pendingInvitation.isPresent()) {
|
||||
UserInvitation invitation = pendingInvitation.get();
|
||||
if (invitation.getExpiresOn().isAfter(LocalDateTime.now())) {
|
||||
session.setAttribute("pendingInvitation", true);
|
||||
session.setAttribute("pendingInvitationToken", invitation.getToken());
|
||||
String orgName = portalOrgRepository.findById(invitation.getOrgId())
|
||||
.map(PortalOrg::getOrgName)
|
||||
.orElse("알 수 없는 기관");
|
||||
session.setAttribute("pendingInvitationOrgName", orgName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 로그인 방지: 기존 세션 강제 로그아웃 + 현재 세션 등록
|
||||
String clientIp = HttpRequestUtil.getClientIpAddress(request);
|
||||
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
|
||||
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
||||
clientIp, request.getHeader("User-Agent"));
|
||||
|
||||
// 물리 세션 타임아웃 10분 고정 (콘솔 override 무관하게 물리=논리 단일화, CSRF 수명 포함)
|
||||
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
|
||||
|
||||
logLoginSuccess(request, session, rawUsername);
|
||||
|
||||
String decisionToken = (String) session.getAttribute("decisionToken");
|
||||
if (decisionToken != null) {
|
||||
return contextPath + "/signup/decision_process";
|
||||
}
|
||||
|
||||
// 미인증 접근으로 저장해 둔 원래 요청 페이지로 복귀(1회용).
|
||||
// 단, 강제 유도 흐름(이메일 인증/휴면/비밀번호 변경 — session.redirectUrl 세팅)이 있으면
|
||||
// 그쪽이 우선이므로 복귀시키지 않고 기본 경로로 보낸다.
|
||||
String postLoginRedirect = (String) session.getAttribute(SESSION_POST_LOGIN_REDIRECT);
|
||||
session.removeAttribute(SESSION_POST_LOGIN_REDIRECT);
|
||||
if (postLoginRedirect != null && session.getAttribute("redirectUrl") == null) {
|
||||
return contextPath + postLoginRedirect;
|
||||
}
|
||||
return contextPath + "/";
|
||||
}
|
||||
|
||||
/** 후속 유도(이메일 인증/휴면/비밀번호 변경) 세션 플래그 세팅 */
|
||||
private void applyPostLoginState(PortalUser user, HttpSession session, String username, String contextPath) {
|
||||
if (isEmailVerificationRequired(user)) {
|
||||
session.setAttribute("success", "이메일 인증이 완료되지 않았습니다. 이메일을 확인하여 인증을 완료해주세요.");
|
||||
session.setAttribute("emailVerificationRequired", true);
|
||||
session.setAttribute("redirectUrl", contextPath + "/mypage/verification-email");
|
||||
} else if (isDormantAccount(user)) {
|
||||
session.setAttribute("success", "90일 이상 미접속하여 계정이 잠금 처리되었습니다. 본인인증 후 이용해주세요.");
|
||||
session.setAttribute("dormantAccount", true);
|
||||
session.setAttribute("dormantLoginId", username);
|
||||
session.setAttribute("redirectUrl", contextPath + "/dormant_account");
|
||||
} else if (isTemporaryPasswordLogin(user)) {
|
||||
applyPasswordChangeState(session,
|
||||
"임시 비밀번호로 로그인하셨습니다. <br>계정 보안을 위해 비밀번호를 변경해 주세요.",
|
||||
contextPath + "/password/change");
|
||||
} else if (isPasswordChangeRequired(user)) {
|
||||
applyPasswordChangeState(session,
|
||||
"비밀번호를 변경한 지 " + portalProperties.getPasswordExpirationDays() + "일이 경과하였습니다.<br>계정 보안을 위해 비밀번호를 변경해 주세요.",
|
||||
contextPath + "/password/change");
|
||||
} else if (user.getPasswordChangeDate() == null) {
|
||||
// 완화 정책: 현재 비밀번호 확인 단계를 제거했으므로 새 비밀번호 폼으로 바로 유도한다.
|
||||
applyPasswordChangeState(session,
|
||||
"계정 보안을 위해 비밀번호 재설정이 필요합니다.<br>비밀번호를 변경해 주세요.",
|
||||
contextPath + "/password/change");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 대상자에게 정책(NONE/PERMISSIVE/ENFORCE)을 적용한다.
|
||||
*/
|
||||
private void applyPasswordChangeState(HttpSession session, String message, String redirectUrl) {
|
||||
PasswordEnforcementPolicy policy = PasswordEnforcementPolicy.from(
|
||||
portalPropertyService.getOrCreateProperty(
|
||||
PasswordEnforcementPolicy.PROPERTY_GROUP,
|
||||
PasswordEnforcementPolicy.PROPERTY_NAME,
|
||||
PasswordEnforcementPolicy.DEFAULT.name(),
|
||||
"비밀번호 변경 강제 정책 (NONE|PERMISSIVE|ENFORCE)"));
|
||||
|
||||
if (policy == PasswordEnforcementPolicy.NONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.setAttribute("success", message);
|
||||
session.setAttribute("passwordExpired", true);
|
||||
session.setAttribute("redirectUrl", redirectUrl);
|
||||
|
||||
if (policy == PasswordEnforcementPolicy.ENFORCE) {
|
||||
session.setAttribute(PasswordChangeEnforcementInterceptor.ENFORCE_SESSION_ATTR, Boolean.TRUE);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPasswordChangeRequired(PortalUser user) {
|
||||
Optional<UserPasswordHistory> latestHistory = passwordHistoryRepository
|
||||
.findTopByUserIdOrderByChangeDateDesc(user.getId());
|
||||
|
||||
if (latestHistory.isPresent()) {
|
||||
LocalDateTime lastChangeDate = latestHistory.get().getChangeDate();
|
||||
return LocalDateTime.now()
|
||||
.minusDays(portalProperties.getPasswordExpirationDays())
|
||||
.isAfter(lastChangeDate);
|
||||
}
|
||||
|
||||
return LocalDateTime.now()
|
||||
.minusDays(portalProperties.getPasswordExpirationDays())
|
||||
.isAfter(user.getCreatedDate());
|
||||
}
|
||||
|
||||
private boolean isTemporaryPasswordLogin(PortalUser user) {
|
||||
Optional<MessageRequest> latestResetRequest = messageRequestRepository.findFirstByEmailAndMessageCodeOrderByRequestDateDesc(
|
||||
user.getLoginId(), MessageCode.USER_PASSWORD_RESET);
|
||||
|
||||
if (latestResetRequest.isPresent()) {
|
||||
Optional<UserPasswordHistory> latestHistory = passwordHistoryRepository
|
||||
.findTopByUserIdOrderByChangeDateDesc(user.getId());
|
||||
return !latestHistory.isPresent() || latestHistory.get().getChangeDate().isBefore(latestResetRequest.get().getRequestDate());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isDormantAccount(PortalUser user) {
|
||||
return PortalUserEnums.UserStatus.DORMANT.equals(user.getUserStatus());
|
||||
}
|
||||
|
||||
private boolean isEmailVerificationRequired(PortalUser user) {
|
||||
return PortalUserEnums.UserStatus.READY.equals(user.getUserStatus());
|
||||
}
|
||||
|
||||
private void logLoginSuccess(HttpServletRequest request, HttpSession session, String username) {
|
||||
StringBuilder logMessage = new StringBuilder();
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("USER LOGIN SUCCESS\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("Username: ").append(StringMaskingUtil.maskLoginId(username)).append("\n");
|
||||
logMessage.append("Session ID: ").append(StringMaskingUtil.maskToken(session.getId())).append("\n");
|
||||
logMessage.append("Login At: ").append(LocalDateTime.now().format(formatter)).append("\n");
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST INFORMATION\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(StringMaskingUtil.maskIpAddress(HttpRequestUtil.getClientIpAddress(request))).append("\n");
|
||||
logMessage.append("Client Host: ").append(HttpRequestUtil.getClientHost(request)).append("\n");
|
||||
logMessage.append("Is Proxied: ").append(HttpRequestUtil.isProxied(request)).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(StringMaskingUtil.maskIpAddress(request.getRemoteAddr())).append("\n");
|
||||
logMessage.append("Remote Host (Direct): ").append(request.getRemoteHost()).append("\n");
|
||||
logMessage.append("Request Method: ").append(request.getMethod()).append("\n");
|
||||
logMessage.append("Request URI: ").append(request.getRequestURI()).append("\n");
|
||||
logMessage.append("Query String: ").append(request.getQueryString()).append("\n");
|
||||
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST HEADERS\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
|
||||
java.util.Enumeration<String> headerNames = request.getHeaderNames();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String headerName = headerNames.nextElement();
|
||||
java.util.Enumeration<String> headerValues = request.getHeaders(headerName);
|
||||
while (headerValues.hasMoreElements()) {
|
||||
String headerValue = headerValues.nextElement();
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, StringMaskingUtil.maskHeaderValue(headerName, headerValue)));
|
||||
}
|
||||
}
|
||||
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
|
||||
sessionLogger.info(logMessage.toString());
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -35,9 +35,14 @@ public class ApiStatisticsController {
|
||||
*/
|
||||
@GetMapping
|
||||
public String statisticsPage(Model model) {
|
||||
// 미로그인 접근은 사유를 노출하도록 reason=auth 로 유도(로그인 페이지 안내 배너)
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
return "redirect:/login?reason=auth";
|
||||
}
|
||||
PortalOrg org = getPortalOrg();
|
||||
if (org == null) {
|
||||
return "redirect:/login";
|
||||
// 로그인은 했으나 조직이 없는(권한 밖) 사용자는 홈으로
|
||||
return "redirect:/";
|
||||
}
|
||||
|
||||
String orgId = org.getId();
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package com.eactive.apim.portal.apps.user.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
||||
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.config.PasswordChangeEnforcementInterceptor;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.dto.*;
|
||||
import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade;
|
||||
@@ -49,6 +53,8 @@ public class AccountController {
|
||||
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final UserSessionService userSessionService;
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final TwoFactorProperties twoFactorProperties;
|
||||
|
||||
|
||||
@PostMapping("/password/confirm")
|
||||
@@ -71,8 +77,14 @@ public class AccountController {
|
||||
}
|
||||
|
||||
@GetMapping("/password/change")
|
||||
public String showNewPasswordPage(Model model) {
|
||||
public String showNewPasswordPage(Model model, HttpSession session) {
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
// 강제 변경(ENFORCE/만료) 진입 시 "변경/로그아웃" 강제 팝업을 띄운다.
|
||||
if (isPasswordEnforced(session)) {
|
||||
model.addAttribute("forcedPasswordReset", true);
|
||||
}
|
||||
// 반영 직전 2FA 필요 여부(강제 변경/2FA off 면 불필요) → 폼 JS 분기용
|
||||
model.addAttribute("twofaRequired", isPwChangeTwofaRequired(session));
|
||||
return "apps/mypage/passwordChange";
|
||||
}
|
||||
|
||||
@@ -97,6 +109,15 @@ public class AccountController {
|
||||
RedirectAttributes redirectAttributes, Model model) {
|
||||
|
||||
try {
|
||||
// 반영 직전 2FA: 통과권이 없으면 커밋하지 않고 폼으로 되돌린다(프론트가 먼저 2FA 팝업을 띄운다).
|
||||
if (isPwChangeTwofaRequired(session)
|
||||
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.PASSWORD_CHANGE)) {
|
||||
model.addAttribute("error", "추가 인증(2FA) 후 다시 시도해 주세요.");
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
model.addAttribute("twofaRequired", true);
|
||||
return "apps/mypage/passwordChange";
|
||||
}
|
||||
|
||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||
userFacade.updatePassword(currentLoginId, newPassword, confirmPassword);
|
||||
|
||||
@@ -122,6 +143,8 @@ public class AccountController {
|
||||
logger.warn("비밀번호 변경 검증 실패: {}", e.getMessage());
|
||||
model.addAttribute("error", e.getMessage());
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
// 2FA 통과권은 이미 소비됨 → 재제출 시 다시 2FA 를 요구하도록 플래그 유지
|
||||
model.addAttribute("twofaRequired", isPwChangeTwofaRequired(session));
|
||||
return "apps/mypage/passwordChange";
|
||||
} catch (Exception e) {
|
||||
// 예기치 못한 오류(트랜잭션 롤백 등) — 원인 추적을 위해 스택은 남기되,
|
||||
@@ -129,13 +152,21 @@ public class AccountController {
|
||||
logger.error("비밀번호 변경 처리 중 오류", e);
|
||||
model.addAttribute("error", "비밀번호 변경 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
model.addAttribute("twofaRequired", isPwChangeTwofaRequired(session));
|
||||
return "apps/mypage/passwordChange";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/mypage")
|
||||
public ModelAndView mypage() {
|
||||
public ModelAndView mypage(HttpSession session) {
|
||||
// 완화된 step-up(PASSWORD 레벨): 통과권이 없으면 현재 비밀번호 확인 페이지로 유도한다.
|
||||
// (내 정보 변경은 2FA 대신 비밀번호 재확인만 요구)
|
||||
if (isMypageStepUpActive()
|
||||
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.MYPAGE)) {
|
||||
return new ModelAndView("redirect:/auth/stepup/password?returnUrl=" + StepUpProtectedPaths.MYPAGE);
|
||||
}
|
||||
|
||||
ModelAndView mav = new ModelAndView();
|
||||
|
||||
try {
|
||||
@@ -248,6 +279,8 @@ public class AccountController {
|
||||
return "redirect:/mypage";
|
||||
} finally {
|
||||
cleanupAuthSession(session);
|
||||
// 수정 저장 후 redirect:/mypage 로 되돌아갈 때 step-up 재요구를 막는다(1회 왕복 한정).
|
||||
twoFactorService.grantStepUpPass(session, StepUpProtectedPaths.MYPAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +293,29 @@ public class AccountController {
|
||||
}
|
||||
}
|
||||
|
||||
/** 내 정보({@code /mypage}) 완화 step-up(비밀번호 재확인)이 현재 활성인지 */
|
||||
private boolean isMypageStepUpActive() {
|
||||
return twoFactorProperties.isStepUpEnabled()
|
||||
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.MYPAGE);
|
||||
}
|
||||
|
||||
/** 비밀번호 강제 변경 상태(ENFORCE/만료)인지 */
|
||||
private boolean isPasswordEnforced(HttpSession session) {
|
||||
return Boolean.TRUE.equals(session.getAttribute(PasswordChangeEnforcementInterceptor.ENFORCE_SESSION_ATTR))
|
||||
|| Boolean.TRUE.equals(session.getAttribute("passwordExpired"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 반영 직전에 2FA 를 요구할지 여부.
|
||||
* 전체/지점 step-up 스위치가 켜져 있고 <b>강제 변경 상태가 아닐 때만</b> 요구한다
|
||||
* (강제/만료 변경은 기존대로 2FA 없이 진행).
|
||||
*/
|
||||
private boolean isPwChangeTwofaRequired(HttpSession session) {
|
||||
return twoFactorProperties.isStepUpEnabled()
|
||||
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.PASSWORD_CHANGE)
|
||||
&& !isPasswordEnforced(session);
|
||||
}
|
||||
|
||||
@GetMapping("/mypage/org-transfer")
|
||||
public String showOrgTransferPage(Model model) {
|
||||
try {
|
||||
@@ -374,7 +430,7 @@ public class AccountController {
|
||||
PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser();
|
||||
|
||||
if (currentUser == null) {
|
||||
return "redirect:/login";
|
||||
return "redirect:/login?reason=auth";
|
||||
}
|
||||
|
||||
// 사용자 이메일 주소를 모델에 추가
|
||||
|
||||
+57
-1
@@ -31,6 +31,7 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import javax.validation.Valid;
|
||||
|
||||
@@ -114,6 +115,7 @@ public class UserRegisterController {
|
||||
@Valid @ModelAttribute("portalUser") PortalUserRegistrationDTO portalUserRegistrationDTO,
|
||||
BindingResult bindingResult,
|
||||
HttpSession session,
|
||||
HttpServletRequest request,
|
||||
RedirectAttributes redirectAttributes,
|
||||
Model model) {
|
||||
try {
|
||||
@@ -140,9 +142,19 @@ public class UserRegisterController {
|
||||
// 성공 시 PRG 패턴 적용: 결과 페이지로 리다이렉트
|
||||
session.removeAttribute("invitationToken");
|
||||
|
||||
// 개인 가입자 중 이메일 인증 대상(READY 상태)은 회원가입 직후 바로 이메일 인증 단계로 이동
|
||||
// 개인 가입자 처리 분기
|
||||
if (invitationToken == null) {
|
||||
Optional<PortalUser> registered = portalUserService.findByLoginId(portalUserRegistrationDTO.getLoginId());
|
||||
|
||||
// 이메일 인증을 마쳐 ACTIVE 로 저장된 경우 → 바로 자동 로그인(2차 인증 없이) 후 메인 이동
|
||||
if (registered.isPresent()
|
||||
&& PortalUserEnums.UserStatus.ACTIVE.equals(registered.get().getUserStatus())) {
|
||||
portalUserAuthService.autoLoginAfterSignup(registered.get(), request);
|
||||
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
|
||||
return "redirect:/";
|
||||
}
|
||||
|
||||
// 이메일 미인증(READY) → 회원가입 직후 이메일 인증 단계로 이동(기존 흐름 유지)
|
||||
if (registered.isPresent()
|
||||
&& PortalUserEnums.UserStatus.READY.equals(registered.get().getUserStatus())) {
|
||||
session.setAttribute("signupVerificationEmail", registered.get().getEmailAddr());
|
||||
@@ -197,6 +209,50 @@ public class UserRegisterController {
|
||||
return "apps/register/signupVerificationEmail";
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 폼 내 이메일 인증코드 발송. 형식·중복 선검증 후 발송하고, 세션에 대상 이메일을 저장한다.
|
||||
* (가입 완료 전, 폼에서 인라인으로 호출)
|
||||
*/
|
||||
@PostMapping("/signup/email-code/send")
|
||||
public ResponseEntity<ValidationResponse> sendSignupFormEmailCode(@RequestParam String email,
|
||||
HttpSession session) {
|
||||
String normalized = email != null ? email.trim().toLowerCase() : null;
|
||||
|
||||
// 형식 + 중복 선검증 (중복 이메일을 인증까지 마친 뒤 가입 단계에서 거절되는 것을 방지)
|
||||
ValidationResponse check = userRegisterFacade.handleCheckNewEmail(normalized);
|
||||
if (!check.isValid()) {
|
||||
return ResponseEntity.ok(check);
|
||||
}
|
||||
|
||||
ValidationResponse response = authFacade.requestAuth(normalized, "EMAIL");
|
||||
if (response.isValid()) {
|
||||
session.setAttribute("signupEmailPending", normalized);
|
||||
session.removeAttribute("signupVerifiedEmail");
|
||||
}
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 폼 내 이메일 인증코드 검증. 성공 시 세션에 인증 완료 이메일을 저장한다.
|
||||
* (가입 제출 시 서버가 이 값과 DTO 이메일 일치를 재검증한다)
|
||||
*/
|
||||
@PostMapping("/signup/email-code/verify")
|
||||
public ResponseEntity<ValidationResponse> verifySignupFormEmailCode(@RequestParam String email,
|
||||
@RequestParam String code,
|
||||
HttpSession session) {
|
||||
String normalized = email != null ? email.trim().toLowerCase() : null;
|
||||
String pending = (String) session.getAttribute("signupEmailPending");
|
||||
if (pending == null || !pending.equalsIgnoreCase(normalized)) {
|
||||
return ResponseEntity.ok(new ValidationResponse(false, "인증 요청된 이메일과 일치하지 않습니다."));
|
||||
}
|
||||
|
||||
ValidationResponse response = authFacade.verifyAuthNumber(normalized, code);
|
||||
if (response.isValid()) {
|
||||
session.setAttribute("signupVerifiedEmail", normalized);
|
||||
}
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 이메일 인증코드 발송. 임의 이메일 타깃 방지를 위해 세션에 저장된 가입 이메일만 사용한다.
|
||||
*/
|
||||
|
||||
@@ -143,11 +143,20 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
|
||||
}
|
||||
|
||||
// 가입 폼에서 이메일 인증을 마쳤는지 확인(세션 signupVerifiedEmail 이 가입 이메일과 일치)
|
||||
String verifiedEmail = (String) session.getAttribute("signupVerifiedEmail");
|
||||
boolean emailVerified = verifiedEmail != null
|
||||
&& verifiedEmail.equalsIgnoreCase(registrationDTO.getLoginId());
|
||||
|
||||
// 3. 사용자 등록 ("personal" 등록 유형으로 가정)
|
||||
PortalUser newUser = portalUserService.registerActiveUser(registrationDTO, "personal");
|
||||
PortalUser newUser = portalUserService.registerActiveUser(registrationDTO, "personal", emailVerified);
|
||||
if (newUser == null) {
|
||||
return new ValidationResponse(false,"사용자 등록에 실패했습니다.");
|
||||
}
|
||||
if (emailVerified) {
|
||||
session.removeAttribute("signupVerifiedEmail");
|
||||
session.removeAttribute("signupEmailPending");
|
||||
}
|
||||
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.user.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
|
||||
import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
|
||||
import com.eactive.apim.portal.common.exception.SystemException;
|
||||
@@ -53,6 +55,7 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final MessageRequestRepository messageRequestRepository;
|
||||
private final EncryptionUtil encryptionUtil;
|
||||
private final LoginFinalizer loginFinalizer;
|
||||
|
||||
@Override
|
||||
@Transactional(noRollbackFor = UsernameNotFoundException.class)
|
||||
@@ -101,6 +104,28 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
SecurityContextHolder.getContext().setAuthentication(newAuth);
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 직후 자동 로그인. formLogin 을 경유하지 않으므로 세션 고정 방어(changeSessionId)를
|
||||
* 수동 수행하고, SuccessHandler 와 동일한 후처리({@link LoginFinalizer})로 세션 등록·감사 기록을 맞춘다.
|
||||
* SuccessHandler 를 타지 않으므로 로그인 2FA 는 자연히 건너뛴다.
|
||||
*
|
||||
* @return 이동 대상 URL
|
||||
*/
|
||||
@Transactional
|
||||
public String autoLoginAfterSignup(PortalUser user, javax.servlet.http.HttpServletRequest request) {
|
||||
// 세션 고정 공격 방어 (form login 미경유 → 수동)
|
||||
request.changeSessionId();
|
||||
|
||||
PortalAuthenticatedUser authUser = buildAuthenticatedUser(user);
|
||||
UsernamePasswordAuthenticationToken token =
|
||||
new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());
|
||||
token.setDetails(authUser);
|
||||
SecurityContextHolder.getContext().setAuthentication(token);
|
||||
|
||||
// 세션 등록 / 감사 성공 기록(SIGNUP_AUTO) / 타임아웃 설정 재사용
|
||||
return loginFinalizer.finalizeLogin(user, user.getLoginId(), request, LoginType.SIGNUP_AUTO);
|
||||
}
|
||||
|
||||
public List<PortalUserDTO> findAllUsersByNameAndMobile(String userName, String mobileNumber) {
|
||||
try {
|
||||
if (mobileNumber == null || !mobileNumber.matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.user.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginFailureReason;
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||
import com.eactive.apim.portal.user.entity.UserLog;
|
||||
import com.eactive.apim.portal.user.repository.UserLogRepository;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -17,23 +19,33 @@ public class PortalUserLogService {
|
||||
}
|
||||
|
||||
public void logSuccess(String userId, String ip, String sessionId) {
|
||||
logSuccess(userId, ip, sessionId, LoginType.NORMAL);
|
||||
}
|
||||
|
||||
public void logSuccess(String userId, String ip, String sessionId, LoginType loginType) {
|
||||
UserLog log = new UserLog();
|
||||
log.setLoginId(userId);
|
||||
log.setLoginTime(LocalDateTime.now());
|
||||
log.setIp(ip);
|
||||
log.setSessionId(sessionId);
|
||||
log.setSuccess(true);
|
||||
log.setLoginType(loginType != null ? loginType.name() : null);
|
||||
|
||||
userLogRepository.save(log);
|
||||
}
|
||||
|
||||
public void logFailure(String userId, String ip, String sessionId) {
|
||||
logFailure(userId, ip, sessionId, LoginFailureReason.UNKNOWN);
|
||||
}
|
||||
|
||||
public void logFailure(String userId, String ip, String sessionId, LoginFailureReason reason) {
|
||||
UserLog log = new UserLog();
|
||||
log.setLoginId(userId);
|
||||
log.setLoginTime(LocalDateTime.now());
|
||||
log.setIp(ip);
|
||||
log.setSessionId(sessionId);
|
||||
log.setSuccess(false);
|
||||
log.setFailureReason(reason != null ? reason.name() : LoginFailureReason.UNKNOWN.name());
|
||||
|
||||
userLogRepository.save(log);
|
||||
}
|
||||
|
||||
@@ -141,6 +141,14 @@ public class PortalUserService {
|
||||
|
||||
// 승인 대기 상태.
|
||||
public PortalUser registerActiveUser(PortalUserRegistrationDTO newUserDTO, String registrationType) {
|
||||
return registerActiveUser(newUserDTO, registrationType, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param emailVerified 가입 폼에서 이메일 인증을 이미 완료했으면 true → 바로 ACTIVE 로 저장
|
||||
* (가입 후 별도 이메일 인증 단계를 건너뛴다)
|
||||
*/
|
||||
public PortalUser registerActiveUser(PortalUserRegistrationDTO newUserDTO, String registrationType, boolean emailVerified) {
|
||||
PortalUser newUser = new PortalUser();
|
||||
mapDtoToEntity(newUser, newUserDTO);
|
||||
setUserProperties(newUser);
|
||||
@@ -149,8 +157,9 @@ public class PortalUserService {
|
||||
|
||||
newUser.setUserStatus(UserStatus.READY);
|
||||
|
||||
// 이메일 인증 기능 비활성화 시 바로 활성화 처리
|
||||
if ("true".equalsIgnoreCase(propertyMap.getOrDefault("disable_features.user_email_verify", ""))) {
|
||||
// 이메일 인증 기능 비활성화 시, 또는 가입 폼에서 이미 인증을 마친 경우 바로 활성화 처리
|
||||
if (emailVerified
|
||||
|| "true".equalsIgnoreCase(propertyMap.getOrDefault("disable_features.user_email_verify", ""))) {
|
||||
newUser.setUserStatus(UserStatus.ACTIVE);
|
||||
}
|
||||
newUser.setApprovalStatus(ApprovalStatus.COMPLETED);
|
||||
|
||||
+55
@@ -7,6 +7,7 @@ import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
@@ -58,6 +59,8 @@ public class PortalGlobalExceptionHandler {
|
||||
public ModelAndView handleAccessDeniedException(HttpServletRequest request, AccessDeniedException ex) {
|
||||
// 미로그인 사용자는 로그인 페이지로 유도, 로그인 상태에서의 권한 부족은 오류 안내 페이지로 표시한다.
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
// 원래 요청 페이지를 세션에 저장 → 로그인+2FA 완료 후 LoginFinalizer 가 복귀시킨다.
|
||||
savePostLoginRedirect(request);
|
||||
return new ModelAndView("redirect:/login?reason=auth");
|
||||
}
|
||||
log.warn("접근 권한 없음: loginId={}, uri={}", StringMaskingUtil.maskLoginId(SecurityUtil.getCurrentLoginId()), request.getRequestURI());
|
||||
@@ -67,6 +70,58 @@ public class PortalGlobalExceptionHandler {
|
||||
return modelAndView;
|
||||
}
|
||||
|
||||
/**
|
||||
* 미인증 상태로 보호 페이지(HTML 화면)에 접근한 GET 요청의 원 경로를 세션에 저장한다.
|
||||
* 로그인+2FA 완료 후 {@link LoginFinalizer} 가 이 값으로 복귀시킨다(1회용).
|
||||
* open redirect / 무의미 복귀를 막기 위해 내부 화면 GET 경로만 저장한다.
|
||||
*/
|
||||
private void savePostLoginRedirect(HttpServletRequest request) {
|
||||
if (!"GET".equalsIgnoreCase(request.getMethod())) {
|
||||
return;
|
||||
}
|
||||
// 페이지 네비게이션만 대상(AJAX/데이터 요청 제외)
|
||||
String accept = request.getHeader("Accept");
|
||||
if (accept == null || !accept.contains("text/html")) {
|
||||
return;
|
||||
}
|
||||
String uri = request.getRequestURI();
|
||||
if (uri == null) {
|
||||
return;
|
||||
}
|
||||
String ctx = request.getContextPath();
|
||||
String path = (ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)) ? uri.substring(ctx.length()) : uri;
|
||||
if (path.isEmpty()) {
|
||||
path = "/";
|
||||
}
|
||||
if (!isSafePostLoginPath(path)) {
|
||||
return;
|
||||
}
|
||||
String qs = request.getQueryString();
|
||||
String target = (qs != null && !qs.isEmpty()) ? path + "?" + qs : path;
|
||||
request.getSession().setAttribute(LoginFinalizer.SESSION_POST_LOGIN_REDIRECT, target);
|
||||
}
|
||||
|
||||
/** 복귀 대상으로 허용할 내부 경로인지(로그인/인증/에러/정적/액션/홈 제외) */
|
||||
private boolean isSafePostLoginPath(String path) {
|
||||
if (path == null || !path.startsWith("/") || path.startsWith("//")) {
|
||||
return false;
|
||||
}
|
||||
return !(path.equals("/")
|
||||
|| path.startsWith("/login")
|
||||
|| path.startsWith("/auth/")
|
||||
|| path.startsWith("/actionLogin")
|
||||
|| path.startsWith("/actionLogout")
|
||||
|| path.startsWith("/error")
|
||||
|| path.startsWith("/css/")
|
||||
|| path.startsWith("/js/")
|
||||
|| path.startsWith("/img/")
|
||||
|| path.startsWith("/webfonts/")
|
||||
|| path.startsWith("/plugins/")
|
||||
|| path.startsWith("/api/")
|
||||
|| path.startsWith("/_proxy")
|
||||
|| path.startsWith("/favicon"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = PortalRedirectException.class)
|
||||
public ModelAndView handlePortalRedirectException(HttpServletRequest request, PortalRedirectException ex) {
|
||||
return new ModelAndView(ex.getMessage());
|
||||
|
||||
+2
-1
@@ -54,7 +54,8 @@ public class PasswordChangeEnforcementInterceptor implements HandlerInterceptor
|
||||
return true;
|
||||
}
|
||||
|
||||
String target = request.getContextPath() + "/password/verify";
|
||||
// 완화 정책: 현재 비밀번호 확인 단계를 제거했으므로 새 비밀번호 폼으로 바로 유도한다.
|
||||
String target = request.getContextPath() + "/password/change";
|
||||
// 이미 목적지면 재리다이렉트하지 않는다(무한 루프 방지).
|
||||
if (request.getRequestURI().equals(target)) {
|
||||
return true;
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.eactive.apim.portal.config;
|
||||
/**
|
||||
* 비밀번호 변경 강제 정책 레벨.
|
||||
*
|
||||
* <p>PTL_PROPERTY (group={@code Portal}, name={@code passwordChangeEnforcement}) 값으로 제어한다.</p>
|
||||
* <p>PTL_PROPERTY (group={@code Portal}, name={@code password.change.enforcement}) 값으로 제어한다.
|
||||
* 키는 DB 관례(점 구분 소문자, 예: {@code session.timeout.minutes})를 따른다.
|
||||
* 값은 enum 명({@code NONE}/{@code PERMISSIVE}/{@code ENFORCE}, 대소문자 무시)이다.</p>
|
||||
* <ul>
|
||||
* <li>{@link #NONE} — 정책 미적용. 안내/강제 없음.</li>
|
||||
* <li>{@link #PERMISSIVE} — 대상자 로그인 시 1회 안내 팝업만. 강제 없음.</li>
|
||||
@@ -17,8 +19,8 @@ public enum PasswordEnforcementPolicy {
|
||||
|
||||
/** PTL_PROPERTY 그룹명 */
|
||||
public static final String PROPERTY_GROUP = "Portal";
|
||||
/** PTL_PROPERTY 이름 */
|
||||
public static final String PROPERTY_NAME = "passwordChangeEnforcement";
|
||||
/** PTL_PROPERTY 이름 (점 구분 소문자 관례) */
|
||||
public static final String PROPERTY_NAME = "password.change.enforcement";
|
||||
/** 기본값 (배포 직후 동작) */
|
||||
public static final PasswordEnforcementPolicy DEFAULT = ENFORCE;
|
||||
|
||||
|
||||
+26
-1
@@ -1,6 +1,7 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginConstants;
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginFailureReason;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||
import com.eactive.apim.portal.common.exception.UserNotFoundException;
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
@@ -14,6 +15,9 @@ import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||
import org.apache.groovy.util.Maps;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.DisabledException;
|
||||
import org.springframework.security.authentication.LockedException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
|
||||
@@ -88,7 +92,7 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
||||
}
|
||||
}
|
||||
|
||||
userLogService.logFailure(username, ip, sessionId);
|
||||
userLogService.logFailure(username, ip, sessionId, resolveFailureReason(exception));
|
||||
|
||||
// 로그인 실패 시 세션 정보 로깅
|
||||
logLoginFailure(request, username, exception);
|
||||
@@ -100,6 +104,27 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
||||
response.sendRedirect(contextPath + "/login");
|
||||
}
|
||||
|
||||
/** 인증 예외 타입 → 감사 로그 실패 사유 코드 매핑 */
|
||||
private LoginFailureReason resolveFailureReason(AuthenticationException exception) {
|
||||
if (exception instanceof UsernameNotFoundException) {
|
||||
return LoginFailureReason.ID_NOT_FOUND;
|
||||
}
|
||||
if (exception instanceof BadCredentialsException) {
|
||||
return LoginFailureReason.PASSWORD_MISMATCH;
|
||||
}
|
||||
if (exception instanceof LockedException) {
|
||||
return LoginFailureReason.ACCOUNT_LOCKED;
|
||||
}
|
||||
if (exception instanceof DisabledException) {
|
||||
return LoginFailureReason.ACCOUNT_DISABLED;
|
||||
}
|
||||
if (exception instanceof SessionAuthenticationException) {
|
||||
return LoginFailureReason.SESSION_AUTH;
|
||||
}
|
||||
logger.warn("미분류 로그인 실패 예외 타입: {}", exception.getClass().getName());
|
||||
return LoginFailureReason.UNKNOWN;
|
||||
}
|
||||
|
||||
private void logLoginFailure(HttpServletRequest request, String username, AuthenticationException exception) {
|
||||
StringBuilder logMessage = new StringBuilder();
|
||||
logMessage.append("\n");
|
||||
|
||||
+34
-215
@@ -1,30 +1,17 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
|
||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
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.LoginFinalizer;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.template.entity.MessageRequest;
|
||||
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -33,227 +20,59 @@ import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Optional;
|
||||
|
||||
|
||||
/**
|
||||
* 로그인 1차 인증(ID/PW) 성공 핸들러.
|
||||
*
|
||||
* <p>로그인 2FA 가 활성화되어 있고 DORMANT 가 아니면, 후처리를 확정하지 않고
|
||||
* 2FA 대기(pending) 상태로 전환한다: 세션에 대기 정보를 저장하고 SecurityContext 를
|
||||
* 비워 사용자를 익명으로 되돌린 뒤 {@code /login?twofactor=1} 로 보낸다. 로그인 페이지가
|
||||
* 공통 2FA 팝업을 자동 오픈하고, 인증 성공 시 {@code TwoFactorService} 가 최종 확정한다.</p>
|
||||
*
|
||||
* <p>2FA off(또는 DORMANT)면 {@link LoginFinalizer} 로 기존과 동일하게 즉시 확정한다.
|
||||
* 실질 후처리 로직은 모두 {@link LoginFinalizer} 로 이관되어 로그인/2FA/가입자동로그인이 공유한다.</p>
|
||||
*/
|
||||
@Service
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
public class PortalAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
|
||||
|
||||
private static final Logger sessionLogger = LoggerFactory.getLogger("eapim.portal.session");
|
||||
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
|
||||
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalProperties portalProperties;
|
||||
private final PortalUserLogService userLogService;
|
||||
private final UserPasswordHistoryRepository passwordHistoryRepository;
|
||||
private final MessageRequestRepository messageRequestRepository;
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final PortalOrgRepository portalOrgRepository;
|
||||
private final UserSessionService userSessionService;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
private final LoginFinalizer loginFinalizer;
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final TwoFactorProperties twoFactorProperties;
|
||||
|
||||
@Override
|
||||
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||
Authentication authentication) throws IOException, ServletException {
|
||||
String username = request.getParameter("id");
|
||||
// 이메일 소문자 변환 적용
|
||||
String normalizedUsername = username != null ? username.toLowerCase() : null;
|
||||
PortalUser user = portalUserRepository.findPortalUserByEmailAddr(normalizedUsername).orElse(null);
|
||||
if (user == null) {
|
||||
response.sendRedirect(request.getContextPath() + "/login?error=true");
|
||||
return;
|
||||
}
|
||||
user.setLoginFailureCount(0);
|
||||
portalUserRepository.save(user);
|
||||
|
||||
String ip = request.getRemoteAddr();
|
||||
String sessionId = request.getSession().getId();
|
||||
userLogService.logSuccess(username, ip, sessionId);
|
||||
boolean dormant = PortalUserEnums.UserStatus.DORMANT.equals(user.getUserStatus());
|
||||
|
||||
String contextPath = request.getContextPath();
|
||||
HttpSession session = request.getSession();
|
||||
// 로그인 2FA: ID/PW 는 맞았으므로 실패카운트만 리셋하고, 최종 확정은 2FA 성공까지 보류한다.
|
||||
if (twoFactorProperties.isLoginEnabled() && !dormant) {
|
||||
user.setLoginFailureCount(0);
|
||||
portalUserRepository.save(user);
|
||||
|
||||
// 세션에 상태 저장
|
||||
if (isEmailVerificationRequired(user)) {
|
||||
session.setAttribute("success", "이메일 인증이 완료되지 않았습니다. 이메일을 확인하여 인증을 완료해주세요.");
|
||||
session.setAttribute("emailVerificationRequired", true);
|
||||
session.setAttribute("redirectUrl", contextPath + "/mypage/verification-email");
|
||||
} else if (isDormantAccount(user)) {
|
||||
session.setAttribute("success", "90일 이상 미접속하여 계정이 잠금 처리되었습니다. 본인인증 후 이용해주세요.");
|
||||
session.setAttribute("dormantAccount", true);
|
||||
session.setAttribute("dormantLoginId", username);
|
||||
session.setAttribute("redirectUrl", contextPath + "/dormant_account");
|
||||
} else if (isTemporaryPasswordLogin(user)) {
|
||||
applyPasswordChangeState(session,
|
||||
"임시 비밀번호로 로그인하셨습니다. <br>계정 보안을 위해 비밀번호를 변경해 주세요.",
|
||||
contextPath + "/password/change");
|
||||
} else if (isPasswordChangeRequired(user)) {
|
||||
applyPasswordChangeState(session,
|
||||
"비밀번호를 변경한 지 " + portalProperties.getPasswordExpirationDays() + "일이 경과하였습니다.<br>계정 보안을 위해 비밀번호를 변경해 주세요.",
|
||||
contextPath + "/password/change");
|
||||
} else if (user.getPasswordChangeDate() == null) {
|
||||
// 비밀번호 변경일 미기록(예: 기존 가입자) → 재설정 대상. 현재 비밀번호 검증 진입 경로로 안내.
|
||||
applyPasswordChangeState(session,
|
||||
"계정 보안을 위해 비밀번호 재설정이 필요합니다.<br>비밀번호를 변경해 주세요.",
|
||||
contextPath + "/password/verify");
|
||||
}
|
||||
HttpSession session = request.getSession();
|
||||
twoFactorService.beginLoginChallenge(session, user);
|
||||
|
||||
// 초대 코드 확인 - ROLE_USER만 확인 (세션에 저장하여 메인 페이지에서 팝업으로 표시)
|
||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
|
||||
// 휴대폰 형식(하이픈 유무)이 달라도 초대와 매칭되도록 정규화 후 조회
|
||||
Optional<UserInvitation> pendingInvitation =
|
||||
userInvitationRepository.findFirstByInvitationMobileAndStatus(
|
||||
PhoneNumberUtil.normalize(user.getMobileNumber()), InvitationStatus.PENDING);
|
||||
// 2FA 완료 전까지 익명 상태로 되돌린다(보호 경로 자동 차단, LoginHandler 튕김 회피).
|
||||
SecurityContextHolder.clearContext();
|
||||
session.removeAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
|
||||
|
||||
if (pendingInvitation.isPresent()) {
|
||||
UserInvitation invitation = pendingInvitation.get();
|
||||
if (invitation.getExpiresOn().isAfter(LocalDateTime.now())) {
|
||||
// 세션에 초대 정보 저장 (메인 페이지에서 팝업으로 표시)
|
||||
session.setAttribute("pendingInvitation", true);
|
||||
session.setAttribute("pendingInvitationToken", invitation.getToken());
|
||||
// orgId로 기관명 조회
|
||||
String orgName = portalOrgRepository.findById(invitation.getOrgId())
|
||||
.map(PortalOrg::getOrgName)
|
||||
.orElse("알 수 없는 기관");
|
||||
session.setAttribute("pendingInvitationOrgName", orgName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 로그인 방지: 기존 세션 강제 로그아웃 플래그 설정 + 현재 세션 등록
|
||||
String clientIp = HttpRequestUtil.getClientIpAddress(request);
|
||||
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
|
||||
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
||||
clientIp, request.getHeader("User-Agent"));
|
||||
|
||||
// 물리 세션 타임아웃 10분 고정. yml(timeout: 10m)·weblogic.xml(timeout-secs 600)과 동일 값이지만
|
||||
// 컨테이너 설정(콘솔 override 등)과 무관하게 보장하기 위해 명시 적용 → 물리=논리 단일화(CSRF 수명 포함).
|
||||
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
|
||||
|
||||
// 로그인 성공 시 세션 정보 로깅
|
||||
logLoginSuccess(request, session, username);
|
||||
|
||||
String decisionToken = (String) request.getSession().getAttribute("decisionToken");
|
||||
if (decisionToken != null) {
|
||||
response.sendRedirect(contextPath + "/signup/decision_process");
|
||||
} else {
|
||||
response.sendRedirect(contextPath + "/");
|
||||
}
|
||||
}
|
||||
|
||||
private void logLoginSuccess(HttpServletRequest request, HttpSession session, String username) {
|
||||
StringBuilder logMessage = new StringBuilder();
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("USER LOGIN SUCCESS\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("Username: ").append(StringMaskingUtil.maskLoginId(username)).append("\n");
|
||||
logMessage.append("Session ID: ").append(StringMaskingUtil.maskToken(session.getId())).append("\n");
|
||||
logMessage.append("Login At: ").append(LocalDateTime.now().format(formatter)).append("\n");
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST INFORMATION\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(StringMaskingUtil.maskIpAddress(HttpRequestUtil.getClientIpAddress(request))).append("\n");
|
||||
logMessage.append("Client Host: ").append(HttpRequestUtil.getClientHost(request)).append("\n");
|
||||
logMessage.append("Is Proxied: ").append(HttpRequestUtil.isProxied(request)).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(StringMaskingUtil.maskIpAddress(request.getRemoteAddr())).append("\n");
|
||||
logMessage.append("Remote Host (Direct): ").append(request.getRemoteHost()).append("\n");
|
||||
logMessage.append("Request Method: ").append(request.getMethod()).append("\n");
|
||||
logMessage.append("Request URI: ").append(request.getRequestURI()).append("\n");
|
||||
logMessage.append("Query String: ").append(request.getQueryString()).append("\n");
|
||||
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST HEADERS\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
|
||||
java.util.Enumeration<String> headerNames = request.getHeaderNames();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String headerName = headerNames.nextElement();
|
||||
java.util.Enumeration<String> headerValues = request.getHeaders(headerName);
|
||||
while (headerValues.hasMoreElements()) {
|
||||
String headerValue = headerValues.nextElement();
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, StringMaskingUtil.maskHeaderValue(headerName, headerValue)));
|
||||
}
|
||||
}
|
||||
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
|
||||
sessionLogger.info(logMessage.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 대상자에게 정책(NONE/PERMISSIVE/ENFORCE)을 적용한다.
|
||||
* <ul>
|
||||
* <li>NONE — 아무것도 하지 않음.</li>
|
||||
* <li>PERMISSIVE — 안내 알림만(로그인 후 dismissible 팝업). 강제 없음.</li>
|
||||
* <li>ENFORCE — 안내 + 세션 강제 플래그 설정 → 인터셉터가 변경 완료까지 접근 차단.</li>
|
||||
* </ul>
|
||||
*/
|
||||
private void applyPasswordChangeState(HttpSession session, String message, String redirectUrl) {
|
||||
PasswordEnforcementPolicy policy = PasswordEnforcementPolicy.from(
|
||||
portalPropertyService.getOrCreateProperty(
|
||||
PasswordEnforcementPolicy.PROPERTY_GROUP,
|
||||
PasswordEnforcementPolicy.PROPERTY_NAME,
|
||||
PasswordEnforcementPolicy.DEFAULT.name(),
|
||||
"비밀번호 변경 강제 정책 (NONE|PERMISSIVE|ENFORCE)"));
|
||||
|
||||
if (policy == PasswordEnforcementPolicy.NONE) {
|
||||
response.sendRedirect(request.getContextPath() + "/login?twofactor=1");
|
||||
return;
|
||||
}
|
||||
|
||||
session.setAttribute("success", message);
|
||||
session.setAttribute("passwordExpired", true);
|
||||
session.setAttribute("redirectUrl", redirectUrl);
|
||||
|
||||
if (policy == PasswordEnforcementPolicy.ENFORCE) {
|
||||
session.setAttribute(PasswordChangeEnforcementInterceptor.ENFORCE_SESSION_ATTR, Boolean.TRUE);
|
||||
}
|
||||
// 2FA off (또는 DORMANT) → 기존과 동일하게 즉시 확정
|
||||
String redirect = loginFinalizer.finalizeLogin(user, username, request, LoginType.NORMAL);
|
||||
response.sendRedirect(redirect);
|
||||
}
|
||||
|
||||
private boolean isPasswordChangeRequired(PortalUser user) {
|
||||
// 가장 최근 비밀번호 변경 이력 조회
|
||||
Optional<UserPasswordHistory> latestHistory = passwordHistoryRepository
|
||||
.findTopByUserIdOrderByChangeDateDesc(user.getId());
|
||||
|
||||
// 비밀번호 변경 이력이 있는 경우
|
||||
if (latestHistory.isPresent()) {
|
||||
LocalDateTime lastChangeDate = latestHistory.get().getChangeDate();
|
||||
return LocalDateTime.now()
|
||||
.minusDays(portalProperties.getPasswordExpirationDays())
|
||||
.isAfter(lastChangeDate);
|
||||
}
|
||||
|
||||
return LocalDateTime.now()
|
||||
.minusDays(portalProperties.getPasswordExpirationDays())
|
||||
.isAfter(user.getCreatedDate());
|
||||
}
|
||||
|
||||
private boolean isTemporaryPasswordLogin(PortalUser user) {
|
||||
Optional<MessageRequest> latestResetRequest = messageRequestRepository.findFirstByEmailAndMessageCodeOrderByRequestDateDesc(
|
||||
user.getLoginId(), MessageCode.USER_PASSWORD_RESET);
|
||||
|
||||
if (latestResetRequest.isPresent()) {
|
||||
|
||||
// 가장 최근 비밀번호 변경 이력 조회
|
||||
Optional<UserPasswordHistory> latestHistory = passwordHistoryRepository
|
||||
.findTopByUserIdOrderByChangeDateDesc(user.getId());
|
||||
return !latestHistory.isPresent() || latestHistory.get().getChangeDate().isBefore(latestResetRequest.get().getRequestDate());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isDormantAccount(PortalUser user) {
|
||||
return PortalUserEnums.UserStatus.DORMANT.equals(user.getUserStatus());
|
||||
}
|
||||
|
||||
private boolean isEmailVerificationRequired(PortalUser user) {
|
||||
return PortalUserEnums.UserStatus.READY.equals(user.getUserStatus());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
public static final String ERROR = "error";
|
||||
|
||||
private final Environment environment;
|
||||
private final com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService;
|
||||
private final com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties;
|
||||
|
||||
// 정적자원 해시 버전닝 토글(application.yml: app.resource-versioning.enabled).
|
||||
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceVersioningEnabled 참고).
|
||||
@@ -52,8 +54,12 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
@Value("${app.resource-caching.enabled:false}")
|
||||
private boolean resourceCachingEnabled;
|
||||
|
||||
public PortalConfigWebDispatcherServlet(Environment environment) {
|
||||
public PortalConfigWebDispatcherServlet(Environment environment,
|
||||
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService,
|
||||
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties) {
|
||||
this.environment = environment;
|
||||
this.twoFactorService = twoFactorService;
|
||||
this.twoFactorProperties = twoFactorProperties;
|
||||
}
|
||||
|
||||
|
||||
@@ -79,13 +85,23 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
String[] staticExcludes = {
|
||||
"/css/**", "/js/**", "/img/**", "/images/**", "/webfonts/**",
|
||||
"/font/**", "/html/**", "/plugins/**", "/favicon.ico",
|
||||
"/api/**"};
|
||||
|
||||
// 비밀번호 변경 강제(ENFORCE) 가드. 정적 자원은 제외한다.
|
||||
registry.addInterceptor(new PasswordChangeEnforcementInterceptor())
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns(
|
||||
"/css/**", "/js/**", "/img/**", "/images/**", "/webfonts/**",
|
||||
"/font/**", "/html/**", "/plugins/**", "/favicon.ico",
|
||||
"/api/**");
|
||||
.excludePathPatterns(staticExcludes);
|
||||
|
||||
// step-up 2FA 가드. 비밀번호 강제 가드 "다음" 순서로 등록(강제 변경 상태가 우선).
|
||||
// 2FA 엔드포인트 자체(/auth/2fa/**)는 제외해 순환을 막는다.
|
||||
registry.addInterceptor(new com.eactive.apim.portal.apps.auth.twofactor.StepUpAuthInterceptor(
|
||||
twoFactorService, twoFactorProperties))
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns(staticExcludes)
|
||||
.excludePathPatterns("/auth/2fa/**");
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -316,6 +316,9 @@ page:
|
||||
api_detail:
|
||||
name: "API 상세보기"
|
||||
path: "/apis/detail"
|
||||
api_testbed:
|
||||
name: "테스트베드"
|
||||
path: "/apis/detail/testbed"
|
||||
community:
|
||||
name: 고객지원
|
||||
path: "#"
|
||||
|
||||
@@ -711,75 +711,6 @@ hr {
|
||||
--transition-smooth: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.design-survey-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
z-index: 400;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.design-survey-bar .survey-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.design-survey-bar .survey-label {
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.design-survey-bar .survey-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.design-survey-bar .survey-btn {
|
||||
padding: 6px 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 20px;
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.design-survey-bar .survey-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: #ffffff;
|
||||
}
|
||||
.design-survey-bar .survey-btn.active {
|
||||
background: #ffffff;
|
||||
color: #667eea;
|
||||
border-color: #ffffff;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.design-survey-bar {
|
||||
height: 40px;
|
||||
}
|
||||
.design-survey-bar .survey-label {
|
||||
display: none;
|
||||
}
|
||||
.design-survey-bar .survey-btn {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
body.design-survey-active .global-header {
|
||||
margin-top: 48px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
body.design-survey-active .global-header {
|
||||
margin-top: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.blind {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -7571,69 +7502,6 @@ button.djb-comment-submit:disabled {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.board-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
@@ -7651,6 +7519,330 @@ button.djb-comment-submit:disabled {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tfa-card {
|
||||
position: relative;
|
||||
width: 520px;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border: 1px solid #DCE2ED;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
color: #0D0E11;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.tfa-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #7F8A95;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.tfa-close:hover {
|
||||
background: #F4F5F9;
|
||||
color: #0D0E11;
|
||||
}
|
||||
|
||||
.tfa-head {
|
||||
padding: 24px 32px;
|
||||
padding-right: 60px;
|
||||
border-bottom: 1px solid #DCE2ED;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.tfa-head .tfa-head-title {
|
||||
font-size: 27px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: #0D0E11;
|
||||
}
|
||||
.tfa-head .tfa-head-brand {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #7F8A95;
|
||||
}
|
||||
|
||||
.tfa-body {
|
||||
padding: 28px 32px 32px;
|
||||
}
|
||||
|
||||
.tfa-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tfa-step .tfa-step-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 100px;
|
||||
background: #BDC7CF;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
flex: none;
|
||||
}
|
||||
.tfa-step .tfa-step-num.is-active {
|
||||
background: #4685EF;
|
||||
}
|
||||
.tfa-step .tfa-step-text {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #7F8A95;
|
||||
}
|
||||
.tfa-step .tfa-step-text.is-active {
|
||||
color: #0D0E11;
|
||||
}
|
||||
|
||||
.tfa-segment {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.tfa-segment .tfa-seg-btn {
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: 1px solid #DCE2ED;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
transition: border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.tfa-segment .tfa-seg-btn .tfa-seg-label {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #0D0E11;
|
||||
}
|
||||
.tfa-segment .tfa-seg-btn .tfa-seg-masked {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
color: #7F8A95;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.tfa-segment .tfa-seg-btn:hover {
|
||||
border-color: #4685EF;
|
||||
}
|
||||
.tfa-segment .tfa-seg-btn.is-active {
|
||||
border: 1.5px solid #4685EF;
|
||||
background: #ECF0FA;
|
||||
}
|
||||
.tfa-segment .tfa-seg-btn.is-active .tfa-seg-label {
|
||||
color: #2A69DE;
|
||||
}
|
||||
|
||||
.tfa-hint {
|
||||
border: 1px solid #DCE2ED;
|
||||
border-radius: 4px;
|
||||
background: #F4F5F9;
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: #7F8A95;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tfa-cta {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: #4685EF;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.tfa-cta:hover {
|
||||
background: #2A69DE;
|
||||
}
|
||||
.tfa-cta:disabled {
|
||||
background: #F4F5F9;
|
||||
color: #BDC7CF;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tfa-code-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.tfa-code-row .tfa-code-input-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #BDC7CF;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
transition: border-color 0.12s ease;
|
||||
}
|
||||
.tfa-code-row .tfa-code-input-wrap:focus-within {
|
||||
border-color: #4685EF;
|
||||
}
|
||||
.tfa-code-row .tfa-code-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: #0D0E11;
|
||||
}
|
||||
.tfa-code-row .tfa-code-input::placeholder {
|
||||
color: #BDC7CF;
|
||||
font-weight: 400;
|
||||
}
|
||||
.tfa-code-row .tfa-resend {
|
||||
flex: none;
|
||||
height: 56px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid #7F8A95;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #0D0E11;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.tfa-code-row .tfa-resend:hover {
|
||||
background: #F4F5F9;
|
||||
}
|
||||
.tfa-code-row .tfa-resend:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tfa-timer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border: 1px solid #DCE2ED;
|
||||
border-radius: 4px;
|
||||
background: #F4F5F9;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tfa-timer-row .tfa-timer-label {
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
color: #7F8A95;
|
||||
}
|
||||
.tfa-timer-row .tfa-timer-clock {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
.tfa-timer-row .tfa-timer {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: #4685EF;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tfa-timer-row .tfa-timer.is-warning {
|
||||
color: #F4253C;
|
||||
}
|
||||
.tfa-timer-row .tfa-timer.is-expired {
|
||||
color: #7F8A95;
|
||||
}
|
||||
.tfa-timer-row .tfa-timer-total {
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: #7F8A95;
|
||||
}
|
||||
|
||||
.tfa-progress {
|
||||
height: 3px;
|
||||
background: #DCE2ED;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tfa-progress .tfa-progress-bar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #4685EF;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
.tfa-progress .tfa-progress-bar.is-warning {
|
||||
background: #F4253C;
|
||||
}
|
||||
|
||||
.tfa-message {
|
||||
min-height: 21px;
|
||||
margin: 0 0 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: #7F8A95;
|
||||
}
|
||||
.tfa-message.is-error {
|
||||
color: #F4253C;
|
||||
}
|
||||
|
||||
.tfa-test-notice {
|
||||
margin: 0 0 16px;
|
||||
padding: 10px 14px;
|
||||
border: 1px dashed #F59E0B;
|
||||
border-radius: 4px;
|
||||
background: #FFFBEB;
|
||||
color: #B45309;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tfa-foot {
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #DFDFDF;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: #7F8A95;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tfa-head {
|
||||
padding: 20px 22px;
|
||||
}
|
||||
.tfa-head .tfa-head-title {
|
||||
font-size: 23px;
|
||||
}
|
||||
.tfa-body {
|
||||
padding: 22px 22px 26px;
|
||||
}
|
||||
.tfa-segment {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
}
|
||||
.hero-carousel-section {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -10975,6 +11167,8 @@ body.index-page-body {
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
bottom: -2px;
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
.api-detail-tabs .tab-button:hover {
|
||||
color: #0049b4;
|
||||
@@ -11003,6 +11197,39 @@ body.index-page-body {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.testbed-auth-gate {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: calc(32px * 2) 24px;
|
||||
text-align: center;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.testbed-auth-gate .testbed-auth-gate__icon {
|
||||
font-size: 40px;
|
||||
line-height: 1;
|
||||
}
|
||||
.testbed-auth-gate .testbed-auth-gate__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1A1A2E;
|
||||
}
|
||||
.testbed-auth-gate .testbed-auth-gate__desc {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #64748B;
|
||||
}
|
||||
.testbed-auth-gate .btn {
|
||||
margin-top: 8px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.testbed-app-panel {
|
||||
margin: 8px 0 0;
|
||||
padding: 24px;
|
||||
@@ -11452,11 +11679,11 @@ body.index-page-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 20px;
|
||||
padding: 100px 20px;
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
margin-top: 60px;
|
||||
margin-bottom: 60px;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
@@ -11751,7 +11978,7 @@ body.index-page-body {
|
||||
@media (max-width: 768px) {
|
||||
.login-page {
|
||||
min-height: calc(100vh - 44px);
|
||||
padding: 40px 20px;
|
||||
padding: 100px 20px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
border-radius: 0;
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,348 @@
|
||||
/*
|
||||
* 공통 2FA(추가 인증) 팝업 모듈.
|
||||
*
|
||||
* TwoFactorAuth.open({
|
||||
* mode: 'login' | 'stepup', // 참고용(서버가 세션으로 판별). 로깅/분기용
|
||||
* purpose: '/myapikey/...', // step-up 대상 보호 경로(로그인은 생략)
|
||||
* onSuccess: function(res){}, // 검증 성공. login 이면 res.redirect 사용
|
||||
* onCancel: function(reason){}// 닫기/타임아웃/실패로 종료
|
||||
* });
|
||||
*
|
||||
* 수신처는 서버가 세션 대상 사용자로부터 결정한다(클라이언트는 채널만 선택).
|
||||
* 모든 POST 는 세션 CSRF(meta[name=_csrf]) 헤더를 함께 보낸다.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var CTX = (function () {
|
||||
var el = document.querySelector('base');
|
||||
return (window.__contextPath !== undefined) ? window.__contextPath : '';
|
||||
})();
|
||||
|
||||
function csrf() {
|
||||
var t = document.querySelector('meta[name="_csrf"]');
|
||||
var h = document.querySelector('meta[name="_csrf_header"]');
|
||||
return {
|
||||
header: h ? h.getAttribute('content') : 'X-XSRF-TOKEN',
|
||||
token: t ? t.getAttribute('content') : ''
|
||||
};
|
||||
}
|
||||
|
||||
function url(path) {
|
||||
return CTX + path;
|
||||
}
|
||||
|
||||
function postForm(path, params) {
|
||||
var c = csrf();
|
||||
var headers = { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' };
|
||||
if (c.token) { headers[c.header] = c.token; }
|
||||
var body = Object.keys(params || {}).map(function (k) {
|
||||
return encodeURIComponent(k) + '=' + encodeURIComponent(params[k]);
|
||||
}).join('&');
|
||||
return fetch(url(path), {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
credentials: 'same-origin',
|
||||
body: body
|
||||
}).then(function (r) {
|
||||
// cancel 은 본문이 없다
|
||||
return r.status === 200 ? r.text().then(function (t) { return t ? JSON.parse(t) : {}; }) : Promise.reject(r);
|
||||
});
|
||||
}
|
||||
|
||||
function getJson(path) {
|
||||
return fetch(url(path), { credentials: 'same-origin' }).then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
var TwoFactorAuth = {
|
||||
_opts: null,
|
||||
_timer: null,
|
||||
_channel: null,
|
||||
_closing: false,
|
||||
|
||||
open: function (options) {
|
||||
var self = this;
|
||||
self._opts = options || {};
|
||||
self._closing = false;
|
||||
var purpose = self._opts.purpose || '';
|
||||
|
||||
getJson('/auth/2fa/info' + (purpose ? ('?purpose=' + encodeURIComponent(purpose)) : ''))
|
||||
.then(function (info) {
|
||||
if (!info || !info.available) {
|
||||
self._fail('인증 대상 정보가 없습니다. 다시 시도해주세요.');
|
||||
return;
|
||||
}
|
||||
self._render(info);
|
||||
self._show();
|
||||
if (info.inProgress) {
|
||||
self._confirmForce(info.message || '진행 중인 다른 인증 절차가 있습니다.');
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
self._fail('추가 인증을 시작할 수 없습니다.');
|
||||
});
|
||||
},
|
||||
|
||||
_render: function (info) {
|
||||
var self = this;
|
||||
self._ttl = info.ttlSeconds || 180;
|
||||
self._testNotice = !!info.testNoticeEnabled;
|
||||
|
||||
var seg = document.getElementById('tfaSegment');
|
||||
seg.innerHTML = '';
|
||||
self._maskedByType = {};
|
||||
var channels = info.channels || [];
|
||||
channels.forEach(function (ch, idx) {
|
||||
self._maskedByType[ch.type] = ch.masked;
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'tfa-seg-btn' + (idx === 0 ? ' is-active' : '');
|
||||
btn.setAttribute('data-channel', ch.type);
|
||||
btn.innerHTML = '<span class="tfa-seg-label">' + (ch.type === 'EMAIL' ? '이메일' : '휴대폰 문자') + '</span>'
|
||||
+ '<span class="tfa-seg-masked">' + ch.masked + '</span>';
|
||||
btn.addEventListener('click', function () { self._selectChannel(ch.type); });
|
||||
seg.appendChild(btn);
|
||||
});
|
||||
self._channel = channels.length ? channels[0].type : null;
|
||||
|
||||
// 초기 상태: 발송 단계
|
||||
document.getElementById('tfaSendStep').style.display = '';
|
||||
document.getElementById('tfaVerifyStep').style.display = 'none';
|
||||
self._setMessage('', false);
|
||||
document.getElementById('tfaCodeInput').value = '';
|
||||
document.getElementById('tfaVerifyButton').disabled = true;
|
||||
self._setStep2Active(false);
|
||||
self._resetTimerUi();
|
||||
var notice = document.getElementById('tfaTestNotice');
|
||||
notice.style.display = 'none';
|
||||
notice.textContent = '';
|
||||
|
||||
// 핸들러 바인딩
|
||||
document.getElementById('tfaSendButton').onclick = function () { self._send(false); };
|
||||
document.getElementById('tfaResendButton').onclick = function () { self._send(false); };
|
||||
document.getElementById('tfaVerifyButton').onclick = function () { self._verify(); };
|
||||
document.getElementById('tfaCloseButton').onclick = function () { self._cancel('CANCELLED'); };
|
||||
document.getElementById('tfaBackdrop').onclick = function () { self._cancel('CANCELLED'); };
|
||||
document.getElementById('tfaCodeInput').oninput = function () {
|
||||
var v = (this.value || '').replace(/\D/g, '').slice(0, 6);
|
||||
this.value = v;
|
||||
document.getElementById('tfaVerifyButton').disabled = v.length < 6;
|
||||
};
|
||||
document.getElementById('tfaCodeInput').onkeydown = function (e) {
|
||||
if (e.key === 'Enter') { self._verify(); }
|
||||
};
|
||||
},
|
||||
|
||||
_selectChannel: function (type) {
|
||||
this._channel = type;
|
||||
var btns = document.querySelectorAll('#tfaSegment .tfa-seg-btn');
|
||||
Array.prototype.forEach.call(btns, function (b) {
|
||||
b.classList.toggle('is-active', b.getAttribute('data-channel') === type);
|
||||
});
|
||||
},
|
||||
|
||||
_send: function (force) {
|
||||
var self = this;
|
||||
if (!self._channel) { return; }
|
||||
var sendBtn = document.getElementById('tfaSendButton');
|
||||
var resendBtn = document.getElementById('tfaResendButton');
|
||||
sendBtn.disabled = true;
|
||||
resendBtn.disabled = true;
|
||||
|
||||
postForm('/auth/2fa/send', {
|
||||
channel: self._channel,
|
||||
purpose: self._opts.purpose || '',
|
||||
force: force ? 'true' : 'false'
|
||||
}).then(function (res) {
|
||||
sendBtn.disabled = false;
|
||||
resendBtn.disabled = false;
|
||||
if (res.inProgress) {
|
||||
self._confirmForce(res.message || '진행 중인 다른 인증 절차가 있습니다.');
|
||||
return;
|
||||
}
|
||||
if (!res.valid) {
|
||||
self._setMessage(res.message || '인증번호 발송에 실패했습니다.', true);
|
||||
return;
|
||||
}
|
||||
// 발송 성공 → 검증 단계 노출 + 타이머
|
||||
document.getElementById('tfaSendStep').style.display = 'none';
|
||||
document.getElementById('tfaVerifyStep').style.display = '';
|
||||
self._setStep2Active(true);
|
||||
var masked = self._maskedByType ? (self._maskedByType[self._channel] || '') : '';
|
||||
self._setMessage((masked ? masked + ' 으로 ' : '') + '인증번호를 보냈습니다.', false);
|
||||
document.getElementById('tfaCodeInput').value = '';
|
||||
document.getElementById('tfaVerifyButton').disabled = true;
|
||||
document.getElementById('tfaCodeInput').focus();
|
||||
if (self._testNotice && res.testAuthNumber) {
|
||||
var notice = document.getElementById('tfaTestNotice');
|
||||
notice.style.display = '';
|
||||
notice.textContent = '[테스트] 인증번호: ' + res.testAuthNumber;
|
||||
}
|
||||
self._startTimer(res.ttlSeconds || self._ttl);
|
||||
}).catch(function () {
|
||||
sendBtn.disabled = false;
|
||||
resendBtn.disabled = false;
|
||||
self._setMessage('인증번호 발송 중 오류가 발생했습니다.', true);
|
||||
});
|
||||
},
|
||||
|
||||
_confirmForce: function (message) {
|
||||
var self = this;
|
||||
var ok = window.confirm(message + '\n강제 종료하고 새로 진행하시겠습니까?');
|
||||
if (ok) {
|
||||
self._send(true);
|
||||
}
|
||||
},
|
||||
|
||||
_verify: function () {
|
||||
var self = this;
|
||||
var code = (document.getElementById('tfaCodeInput').value || '').trim();
|
||||
if (!/^[0-9]{6}$/.test(code)) {
|
||||
self._setMessage('6자리 인증번호를 입력해주세요.', true);
|
||||
return;
|
||||
}
|
||||
var btn = document.getElementById('tfaVerifyButton');
|
||||
btn.disabled = true;
|
||||
postForm('/auth/2fa/verify', { code: code }).then(function (res) {
|
||||
btn.disabled = false;
|
||||
if (res.valid) {
|
||||
self._stopTimer();
|
||||
self._closing = true;
|
||||
self._hide();
|
||||
if (typeof self._opts.onSuccess === 'function') {
|
||||
self._opts.onSuccess(res);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (res.terminated) {
|
||||
self._stopTimer();
|
||||
self._fail(res.message || '인증에 실패했습니다. 처음부터 다시 진행해주세요.');
|
||||
return;
|
||||
}
|
||||
self._setMessage(res.message || '인증번호가 일치하지 않습니다.', true);
|
||||
}).catch(function () {
|
||||
btn.disabled = false;
|
||||
self._setMessage('인증 처리 중 오류가 발생했습니다.', true);
|
||||
});
|
||||
},
|
||||
|
||||
_cancel: function (reason) {
|
||||
var self = this;
|
||||
if (self._closing) { return; }
|
||||
self._closing = true;
|
||||
self._stopTimer();
|
||||
postForm('/auth/2fa/cancel', { reason: reason }).catch(function () {}).then(function () {
|
||||
self._hide();
|
||||
if (typeof self._opts.onCancel === 'function') {
|
||||
self._opts.onCancel(reason);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_fmtClock: function (n) {
|
||||
var m = Math.floor(n / 60);
|
||||
var s = n % 60;
|
||||
return m + ':' + (s < 10 ? '0' + s : s);
|
||||
},
|
||||
|
||||
_setStep2Active: function (active) {
|
||||
var num = document.getElementById('tfaStepNum');
|
||||
var text = document.getElementById('tfaStepText');
|
||||
if (num) { num.classList.toggle('is-active', active); }
|
||||
if (text) { text.classList.toggle('is-active', active); }
|
||||
},
|
||||
|
||||
_resetTimerUi: function () {
|
||||
var base = this._ttl || 180;
|
||||
var clock = document.getElementById('tfaTimer');
|
||||
var bar = document.getElementById('tfaProgressBar');
|
||||
var label = document.getElementById('tfaTimerLabel');
|
||||
var total = document.getElementById('tfaTimerTotal');
|
||||
if (clock) { clock.className = 'tfa-timer'; clock.textContent = this._fmtClock(base); }
|
||||
if (bar) { bar.className = 'tfa-progress-bar'; bar.style.width = '100%'; }
|
||||
if (label) { label.textContent = '남은 인증 시간'; }
|
||||
if (total) { total.textContent = '/ ' + this._fmtClock(base); }
|
||||
},
|
||||
|
||||
_startTimer: function (seconds) {
|
||||
var self = this;
|
||||
self._stopTimer();
|
||||
var total = seconds || self._ttl || 180;
|
||||
var remaining = seconds;
|
||||
var clock = document.getElementById('tfaTimer');
|
||||
var bar = document.getElementById('tfaProgressBar');
|
||||
var label = document.getElementById('tfaTimerLabel');
|
||||
var totalEl = document.getElementById('tfaTimerTotal');
|
||||
if (label) { label.textContent = '남은 인증 시간'; }
|
||||
if (totalEl) { totalEl.textContent = '/ ' + self._fmtClock(total); }
|
||||
function tick() {
|
||||
if (remaining <= 0) {
|
||||
self._stopTimer();
|
||||
if (clock) { clock.textContent = '0:00'; clock.className = 'tfa-timer is-expired'; }
|
||||
if (bar) { bar.style.width = '0%'; }
|
||||
if (label) { label.textContent = '인증 시간이 만료되었습니다. 재전송해 주세요.'; }
|
||||
self._cancel('TIMEOUT');
|
||||
return;
|
||||
}
|
||||
var warn = remaining <= 30;
|
||||
if (clock) {
|
||||
clock.textContent = self._fmtClock(remaining);
|
||||
clock.className = 'tfa-timer' + (warn ? ' is-warning' : '');
|
||||
}
|
||||
if (bar) {
|
||||
bar.style.width = Math.round((remaining / total) * 100) + '%';
|
||||
bar.className = 'tfa-progress-bar' + (warn ? ' is-warning' : '');
|
||||
}
|
||||
remaining--;
|
||||
}
|
||||
tick();
|
||||
self._timer = setInterval(tick, 1000);
|
||||
},
|
||||
|
||||
_stopTimer: function () {
|
||||
if (this._timer) {
|
||||
clearInterval(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
},
|
||||
|
||||
_setMessage: function (msg, isError) {
|
||||
var el = document.getElementById('tfaMessage');
|
||||
el.textContent = msg || '';
|
||||
el.className = 'tfa-message' + (isError ? ' is-error' : '');
|
||||
},
|
||||
|
||||
_fail: function (message) {
|
||||
var self = this;
|
||||
self._stopTimer();
|
||||
self._hide();
|
||||
if (typeof window.customPopups !== 'undefined' && customPopups.showAlert) {
|
||||
customPopups.showAlert(message);
|
||||
} else if (message) {
|
||||
window.alert(message);
|
||||
}
|
||||
if (typeof self._opts.onCancel === 'function') {
|
||||
self._opts.onCancel('FAILED');
|
||||
}
|
||||
},
|
||||
|
||||
_show: function () {
|
||||
// 모달은 컨테이너 display + backdrop/modal 의 .show 클래스(visibility/opacity) 둘 다 필요
|
||||
document.getElementById('tfaPopup').style.display = 'block';
|
||||
var bd = document.getElementById('tfaBackdrop');
|
||||
var md = document.getElementById('tfaModal');
|
||||
if (bd) bd.classList.add('show');
|
||||
if (md) md.classList.add('show');
|
||||
},
|
||||
|
||||
_hide: function () {
|
||||
var bd = document.getElementById('tfaBackdrop');
|
||||
var md = document.getElementById('tfaModal');
|
||||
if (bd) bd.classList.remove('show');
|
||||
if (md) md.classList.remove('show');
|
||||
document.getElementById('tfaPopup').style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
global.TwoFactorAuth = TwoFactorAuth;
|
||||
})(window);
|
||||
@@ -0,0 +1,353 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/color-functions' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
|
||||
// 공통 2FA(추가 인증) 팝업 — 제주은행(DJBank) ERP뱅킹 Design Guide 적용.
|
||||
// .modal / .modal-backdrop / .modal 은 _modals 의 show/hide·애니메이션을 재사용하고
|
||||
// 카드 내부는 아래 토큰(surface_blue #4685EF · border_gray #DCE2ED · radius 4)으로 재정의한다.
|
||||
|
||||
// 디자인 토큰(가이드 전용, 전역 변수와 분리)
|
||||
$tfa-blue: #4685EF;
|
||||
$tfa-blue-dark: #2A69DE;
|
||||
$tfa-blue-soft: #ECF0FA;
|
||||
$tfa-blue-text: #2A69DE;
|
||||
$tfa-ink: #0D0E11;
|
||||
$tfa-muted: #7F8A95;
|
||||
$tfa-border: #DCE2ED;
|
||||
$tfa-border-2: #BDC7CF;
|
||||
$tfa-surface: #F4F5F9;
|
||||
$tfa-danger: #F4253C;
|
||||
|
||||
// 카드 (modal-dialog 기본 padding/max-width/radius 를 덮어씀)
|
||||
.tfa-card {
|
||||
position: relative;
|
||||
width: 520px;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border: 1px solid $tfa-border;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
color: $tfa-ink;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
// 닫기 (우상단, 은은한 회색)
|
||||
.tfa-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: $tfa-muted;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background: $tfa-surface;
|
||||
color: $tfa-ink;
|
||||
}
|
||||
}
|
||||
|
||||
// 헤더
|
||||
.tfa-head {
|
||||
padding: 24px 32px;
|
||||
padding-right: 60px; // 우상단 닫기(✕) 영역 확보 — 브랜드 텍스트와 겹치지 않게
|
||||
border-bottom: 1px solid $tfa-border;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
.tfa-head-title {
|
||||
font-size: 27px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: $tfa-ink;
|
||||
}
|
||||
|
||||
.tfa-head-brand {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: $tfa-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.tfa-body {
|
||||
padding: 28px 32px 32px;
|
||||
}
|
||||
|
||||
// 단계 라벨 (① 인증 수단 / ② 인증번호 입력)
|
||||
.tfa-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.tfa-step-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 100px;
|
||||
background: $tfa-border-2;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
flex: none;
|
||||
|
||||
&.is-active { background: $tfa-blue; }
|
||||
}
|
||||
|
||||
.tfa-step-text {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: $tfa-muted;
|
||||
|
||||
&.is-active { color: $tfa-ink; }
|
||||
}
|
||||
}
|
||||
|
||||
// 채널 선택 (이메일 / 휴대폰) — 2열 그리드
|
||||
.tfa-segment {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 28px;
|
||||
|
||||
.tfa-seg-btn {
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: 1px solid $tfa-border;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
transition: border-color 0.12s ease, background 0.12s ease;
|
||||
|
||||
.tfa-seg-label {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: $tfa-ink;
|
||||
}
|
||||
|
||||
.tfa-seg-masked {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
color: $tfa-muted;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&:hover { border-color: $tfa-blue; }
|
||||
|
||||
&.is-active {
|
||||
border: 1.5px solid $tfa-blue;
|
||||
background: $tfa-blue-soft;
|
||||
|
||||
.tfa-seg-label { color: $tfa-blue-text; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 안내 박스 (발송 전)
|
||||
.tfa-hint {
|
||||
border: 1px solid $tfa-border;
|
||||
border-radius: 4px;
|
||||
background: $tfa-surface;
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: $tfa-muted;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
// 기본 CTA (인증번호 받기 / 인증 완료)
|
||||
.tfa-cta {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: $tfa-blue;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
transition: background 0.12s ease;
|
||||
|
||||
&:hover { background: $tfa-blue-dark; }
|
||||
|
||||
&:disabled {
|
||||
background: $tfa-surface;
|
||||
color: $tfa-border-2;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
// 인증번호 입력 + 재전송
|
||||
.tfa-code-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.tfa-code-input-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid $tfa-border-2;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
transition: border-color 0.12s ease;
|
||||
|
||||
&:focus-within { border-color: $tfa-blue; }
|
||||
}
|
||||
|
||||
.tfa-code-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: $tfa-ink;
|
||||
|
||||
&::placeholder {
|
||||
color: $tfa-border-2;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.tfa-resend {
|
||||
flex: none;
|
||||
height: 56px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid $tfa-muted;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: $tfa-ink;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: background 0.12s ease;
|
||||
|
||||
&:hover { background: $tfa-surface; }
|
||||
&:disabled { opacity: 0.5; cursor: default; }
|
||||
}
|
||||
}
|
||||
|
||||
// 타이머 행
|
||||
.tfa-timer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border: 1px solid $tfa-border;
|
||||
border-radius: 4px;
|
||||
background: $tfa-surface;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.tfa-timer-label {
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
color: $tfa-muted;
|
||||
}
|
||||
|
||||
.tfa-timer-clock {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tfa-timer {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: $tfa-blue;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&.is-warning { color: $tfa-danger; }
|
||||
&.is-expired { color: $tfa-muted; }
|
||||
}
|
||||
|
||||
.tfa-timer-total {
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: $tfa-muted;
|
||||
}
|
||||
}
|
||||
|
||||
// 진행 바
|
||||
.tfa-progress {
|
||||
height: 3px;
|
||||
background: $tfa-border;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.tfa-progress-bar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: $tfa-blue;
|
||||
transition: width 1s linear;
|
||||
|
||||
&.is-warning { background: $tfa-danger; }
|
||||
}
|
||||
}
|
||||
|
||||
// 발송 안내 / 오류 메시지
|
||||
.tfa-message {
|
||||
min-height: 21px;
|
||||
margin: 0 0 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: $tfa-muted;
|
||||
|
||||
&.is-error { color: $tfa-danger; }
|
||||
}
|
||||
|
||||
// 테스트 인증번호 노출(개발/스테이지)
|
||||
.tfa-test-notice {
|
||||
margin: 0 0 16px;
|
||||
padding: 10px 14px;
|
||||
border: 1px dashed #F59E0B;
|
||||
border-radius: 4px;
|
||||
background: #FFFBEB;
|
||||
color: #B45309;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// 하단 안내
|
||||
.tfa-foot {
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #DFDFDF;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: $tfa-muted;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
.tfa-head { padding: 20px 22px; }
|
||||
.tfa-head .tfa-head-title { font-size: 23px; }
|
||||
.tfa-body { padding: 22px 22px 26px; }
|
||||
.tfa-segment { margin-bottom: 22px; }
|
||||
}
|
||||
@@ -44,92 +44,6 @@
|
||||
--transition-smooth: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Design Survey Bar
|
||||
// ===========================
|
||||
.design-survey-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
z-index: 400;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.survey-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.survey-label {
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.survey-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.survey-btn {
|
||||
padding: 6px 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 20px;
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: #ffffff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #ffffff;
|
||||
color: #667eea;
|
||||
border-color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
height: 40px;
|
||||
|
||||
.survey-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.survey-btn {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Body offset when survey is active
|
||||
body.design-survey-active {
|
||||
.global-header {
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.global-header {
|
||||
margin-top: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 디자인 변형 스타일은 JavaScript에서 동적으로 적용됩니다.
|
||||
// header_container.html의 DESIGN_OPTIONS 참조
|
||||
|
||||
// Blind text for screen readers
|
||||
.blind {
|
||||
position: absolute;
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
@use 'components/breadcrumb' as *;
|
||||
@use 'components/test-env-notice' as *;
|
||||
@use 'components/djb-inquiry-comments' as *;
|
||||
@use 'components/password-policy' as *;
|
||||
@use 'components/board-common' as *;
|
||||
@use 'components/two-factor' as *;
|
||||
|
||||
// 5. Page-specific styles
|
||||
@use 'pages/index' as *;
|
||||
|
||||
@@ -645,6 +645,9 @@
|
||||
transition: $transition-base;
|
||||
position: relative;
|
||||
bottom: -2px;
|
||||
// 탭이 <a> 링크로 전환됨(별도 URL) — 링크 기본 스타일 제거
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: $primary-blue;
|
||||
@@ -680,6 +683,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Testbed 미인증 안내 — UI는 노출하되 접근 시 로그인 요구
|
||||
.testbed-auth-gate {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-md;
|
||||
padding: calc(#{$spacing-xl} * 2) $spacing-lg;
|
||||
text-align: center;
|
||||
background: $white;
|
||||
border: 1px solid $border-gray;
|
||||
border-radius: $border-radius-lg;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
.testbed-auth-gate__icon {
|
||||
font-size: 40px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.testbed-auth-gate__title {
|
||||
margin: 0;
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $text-dark;
|
||||
}
|
||||
|
||||
.testbed-auth-gate__desc {
|
||||
margin: 0;
|
||||
font-size: $font-size-base;
|
||||
color: $text-gray;
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin-top: $spacing-sm;
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
// Testbed 앱 선택 패널 (DJPGPT0001)
|
||||
.testbed-app-panel {
|
||||
margin: $spacing-sm 0 0;
|
||||
|
||||
@@ -55,14 +55,18 @@
|
||||
<!-- API Content -->
|
||||
<div class="api-detail-content">
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<!-- Tab Navigation: API 정보 / 테스트베드는 각각 별도 URL(딥링크). 활성 탭은 서버에서 결정 -->
|
||||
<div class="api-detail-tabs">
|
||||
<button class="tab-button active" data-tab="api-info">API 정보</button>
|
||||
<button class="tab-button" data-tab="testbed">테스트베드</button>
|
||||
<a class="tab-button"
|
||||
th:classappend="${activeTab == 'testbed'} ? '' : 'active'"
|
||||
th:href="@{/apis/detail(id=${apiSpecInfo.apiId})}">API 정보</a>
|
||||
<a class="tab-button"
|
||||
th:classappend="${activeTab == 'testbed'} ? 'active' : ''"
|
||||
th:href="@{/apis/detail/testbed(id=${apiSpecInfo.apiId})}">테스트베드</a>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content active" id="api-info-tab">
|
||||
<div class="tab-content" th:classappend="${activeTab == 'testbed'} ? '' : 'active'" id="api-info-tab">
|
||||
<!-- API Overview Card (Merged with Additional Information) -->
|
||||
<div class="api-overview-card">
|
||||
<div class="org-section-header org-section-header--agreement">
|
||||
@@ -141,26 +145,38 @@
|
||||
</div>
|
||||
|
||||
<!-- Testbed Tab Content -->
|
||||
<div class="tab-content" id="testbed-tab">
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/swagger-ui.css}"/>
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/index.css}"/>
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/djb-swagger-testbed.css}"/>
|
||||
<div class="tab-content" th:classappend="${activeTab == 'testbed'} ? 'active' : ''" id="testbed-tab">
|
||||
|
||||
<!-- DJPGPT0001: 앱(인증키) 선택 → 인증 정보 자동 주입 -->
|
||||
<div class="testbed-app-panel" id="appsWrap">
|
||||
<div class="testbed-app-panel__head">
|
||||
<span class="testbed-app-panel__title">앱 선택</span>
|
||||
<span class="testbed-app-panel__desc">테스트할 앱(인증키)을 선택하면 인증 정보가 자동 입력됩니다.</span>
|
||||
</div>
|
||||
<div class="testbed-app-field">
|
||||
<select id="apps">
|
||||
<option value="">앱을 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="testbed-app-notice" id="appsNotice" style="display:none;"></p>
|
||||
<!-- 미인증: 테스트베드는 로그인 후 이용 가능. UI는 노출하되 접근 시 인증 요구 -->
|
||||
<div class="testbed-auth-gate" th:unless="${authenticated}">
|
||||
<div class="testbed-auth-gate__icon">🔒</div>
|
||||
<p class="testbed-auth-gate__title">테스트베드는 로그인 후 이용 가능합니다.</p>
|
||||
<p class="testbed-auth-gate__desc">로그인하면 본인 앱(인증키)으로 API를 직접 호출·테스트할 수 있습니다.</p>
|
||||
<a class="btn btn-primary" th:href="@{/login(reason='auth')}">로그인하기</a>
|
||||
</div>
|
||||
|
||||
<div id="swagger-ui"></div>
|
||||
<!-- 인증: Swagger 테스트베드 -->
|
||||
<th:block th:if="${authenticated}">
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/swagger-ui.css}"/>
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/index.css}"/>
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/djb-swagger-testbed.css}"/>
|
||||
|
||||
<!-- DJPGPT0001: 앱(인증키) 선택 → 인증 정보 자동 주입 -->
|
||||
<div class="testbed-app-panel" id="appsWrap">
|
||||
<div class="testbed-app-panel__head">
|
||||
<span class="testbed-app-panel__title">앱 선택</span>
|
||||
<span class="testbed-app-panel__desc">테스트할 앱(인증키)을 선택하면 인증 정보가 자동 입력됩니다.</span>
|
||||
</div>
|
||||
<div class="testbed-app-field">
|
||||
<select id="apps">
|
||||
<option value="">앱을 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="testbed-app-notice" id="appsNotice" style="display:none;"></p>
|
||||
</div>
|
||||
|
||||
<div id="swagger-ui"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -255,42 +271,13 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Tab functionality
|
||||
const tabButtons = document.querySelectorAll('.tab-button');
|
||||
const tabContents = document.querySelectorAll('.tab-content');
|
||||
|
||||
tabButtons.forEach(function(button) {
|
||||
button.addEventListener('click', function() {
|
||||
const targetTab = this.getAttribute('data-tab');
|
||||
|
||||
// Remove active class from all buttons and contents
|
||||
tabButtons.forEach(function(btn) {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
tabContents.forEach(function(content) {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
|
||||
// Add active class to clicked button and corresponding content
|
||||
this.classList.add('active');
|
||||
document.getElementById(targetTab + '-tab').classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Swagger UI functionality for testbed tab
|
||||
let swaggerInitialized = false;
|
||||
// 탭(API 정보 / 테스트베드)은 각각 별도 URL의 <a> 링크로 전환된다.
|
||||
// 활성 탭은 서버가 결정하고, 테스트베드 탭이 활성이면서 인증된 경우에만
|
||||
// Swagger UI를 초기화한다(실제 초기화는 하단에서 실행 — const 선언 이후).
|
||||
const currentApiId = /*[[${apiSpecInfo.apiId}]]*/ 'default';
|
||||
|
||||
// Initialize Swagger UI when testbed tab is clicked
|
||||
tabButtons.forEach(function(button) {
|
||||
button.addEventListener('click', function() {
|
||||
const targetTab = this.getAttribute('data-tab');
|
||||
if (targetTab === 'testbed' && !swaggerInitialized) {
|
||||
initializeSwagger(currentApiId);
|
||||
swaggerInitialized = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
const activeTab = /*[[${activeTab}]]*/ 'api-info';
|
||||
const isAuthenticated = /*[[${authenticated}]]*/ false;
|
||||
const testbedActive = (activeTab === 'testbed' && isAuthenticated);
|
||||
|
||||
function initializeSwagger(apiId) {
|
||||
if (!apiId || apiId === 'default') {
|
||||
@@ -517,10 +504,14 @@
|
||||
.catch(function (e) { console.error('앱 인증정보 주입 실패', e); });
|
||||
}
|
||||
|
||||
if (appsSelect) {
|
||||
appsSelect.addEventListener('change', function () { onAppSelected(this.value); });
|
||||
// 테스트베드 탭 활성 + 인증 상태일 때만 Swagger UI/앱 인증 컨텍스트 초기화
|
||||
if (testbedActive) {
|
||||
if (appsSelect) {
|
||||
appsSelect.addEventListener('change', function () { onAppSelected(this.value); });
|
||||
}
|
||||
initializeSwagger(currentApiId);
|
||||
loadTestbedContext();
|
||||
}
|
||||
loadTestbedContext();
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<section class="service-hero">
|
||||
<div class="service-hero__inner">
|
||||
<div class="service-hero__icon-wrapper">
|
||||
<img th:src="@{/img/keyimage/api_img.png}" alt="OPEN API 3D 아이콘" class="service-keyImg" />
|
||||
<img th:src="@{/img/keyimage/api_img.svg}" alt="OPEN API 3D 아이콘" class="service-keyImg" />
|
||||
</div>
|
||||
<div class="service-hero__content">
|
||||
<div class="service-hero__badge">
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/djbank_title_layout}">
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>본인 확인</h1>
|
||||
</div>
|
||||
</section>
|
||||
<section layout:fragment="contentFragment">
|
||||
<div class="service-main">
|
||||
<div class="app-management-content">
|
||||
<div class="password-change-wrapper">
|
||||
<h2 class="page-outer-title">본인 확인</h2>
|
||||
|
||||
<form th:action="@{/auth/stepup/password}" method="post">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
||||
<input type="hidden" name="returnUrl" th:value="${returnUrl}"/>
|
||||
|
||||
<div class="register-form-container">
|
||||
<div class="info-notice-box">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 16v-4"></path>
|
||||
<path d="M12 8h.01"></path>
|
||||
</svg>
|
||||
<p>내 정보 보호를 위하여 현재 비밀번호를 다시 입력해 주세요</p>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">현재 비밀번호</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<input type="password" name="currentPassword" class="form-input"
|
||||
placeholder="비밀번호 입력" required autofocus>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions" style="justify-content: flex-end;">
|
||||
<div class="right-buttons">
|
||||
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
|
||||
<button type="submit" class="btn-apply btn-primary">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script th:if="${error}" th:inline="javascript">
|
||||
$(document).ready(function () {
|
||||
customPopups.showAlert([[${error}]]);
|
||||
})
|
||||
</script>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,142 @@
|
||||
<!doctype html>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/djbank_base_layout}">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>추가 인증</title>
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<style>
|
||||
/* Sticky footer: body 를 세로 플렉스로 만들어 콘텐츠가 남는 높이를 채우고
|
||||
푸터가 항상 뷰포트 하단에 붙도록(푸터 아래 빈 공간 제거). 이 페이지에서만 적용. */
|
||||
body { display: flex; flex-direction: column; min-height: 100vh; }
|
||||
body > .container { flex: 1 0 auto; } /* 본문 컨테이너(헤더/푸터 내부 .container 아님) */
|
||||
|
||||
/* 안내는 상단에서부터 노출(세로 중앙정렬 X → 팝업 뒤에 가려지지 않게) */
|
||||
.tfa-challenge {
|
||||
/* 상단 = 고정 헤더 높이(80px) + 여백 40px. 이 페이지는 .container 를 직접 쓰므로
|
||||
.content-wrapper .main-content 의 헤더 오프셋을 못 받아 직접 보정. */
|
||||
padding: 120px 16px 48px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.tfa-challenge {
|
||||
padding-top: 100px; /* 모바일 헤더 최대 60px + 여백 40px */
|
||||
}
|
||||
}
|
||||
.tfa-challenge-card {
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
margin: 0 auto; /* 가로 중앙 */
|
||||
}
|
||||
.tfa-challenge-icon {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
margin: 0 auto 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #E8F0FE 0%, #DCE7FB 100%);
|
||||
box-shadow: 0 8px 24px rgba(0, 73, 180, 0.15);
|
||||
}
|
||||
.tfa-challenge-icon svg { width: 44px; height: 44px; color: #0049B4; }
|
||||
.tfa-challenge-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1E293B;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.tfa-challenge-desc {
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: #64748B;
|
||||
margin: 0;
|
||||
}
|
||||
.tfa-challenge-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 20px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
background: #F1F5F9;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tfa-challenge-badge svg { width: 15px; height: 15px; }
|
||||
@media (max-width: 640px) {
|
||||
.tfa-challenge { padding: 32px 16px; }
|
||||
.tfa-challenge-icon { width: 72px; height: 72px; }
|
||||
.tfa-challenge-icon svg { width: 36px; height: 36px; }
|
||||
.tfa-challenge-title { font-size: 19px; }
|
||||
}
|
||||
</style>
|
||||
<div class="tfa-challenge">
|
||||
<div class="tfa-challenge-card">
|
||||
<div class="tfa-challenge-icon" aria-hidden="true">
|
||||
<!-- shield-check -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
||||
<path d="M9 12l2 2 4-4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="tfa-challenge-title">추가 인증이 필요합니다</h2>
|
||||
<p class="tfa-challenge-desc">
|
||||
회원님의 소중한 정보를 안전하게 보호하기 위해<br>
|
||||
추가 인증을 진행해 주세요.
|
||||
</p>
|
||||
<span class="tfa-challenge-badge" aria-hidden="true">
|
||||
<!-- lock -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
제주은행(DJBank) 보안 인증
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:inline="javascript">
|
||||
(function () {
|
||||
var ctxRoot = /*[[@{/}]]*/ '/';
|
||||
var contextPath = ctxRoot.replace(/\/$/, '');
|
||||
var purpose = /*[[${purpose}]]*/ '';
|
||||
var returnUrl = /*[[${returnUrl}]]*/ '';
|
||||
|
||||
function goReturn() {
|
||||
window.location.href = contextPath + returnUrl;
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (typeof TwoFactorAuth === 'undefined') {
|
||||
setTimeout(open, 50);
|
||||
return;
|
||||
}
|
||||
TwoFactorAuth.open({
|
||||
mode: 'stepup',
|
||||
purpose: purpose,
|
||||
onSuccess: function () { goReturn(); },
|
||||
onCancel: function () {
|
||||
// 취소/실패 → 진입 이전(직전 페이지)으로. 없으면 홈으로.
|
||||
if (document.referrer && document.referrer.indexOf(location.host) !== -1) {
|
||||
window.location.href = document.referrer;
|
||||
} else {
|
||||
window.location.href = contextPath + '/';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', open);
|
||||
})();
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -16,7 +16,7 @@
|
||||
<section class="service-hero">
|
||||
<div class="service-hero__inner">
|
||||
<div class="service-hero__icon-wrapper">
|
||||
<img th:src="@{/img/keyimage/notice_img.png}" alt="공지사항 아이콘" class="service-keyImg" />
|
||||
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘" class="service-keyImg" />
|
||||
</div>
|
||||
<div class="service-hero__content">
|
||||
<div class="service-hero__badge">
|
||||
|
||||
@@ -260,6 +260,25 @@
|
||||
}
|
||||
});
|
||||
|
||||
// 로그인 2FA: 1차 인증 통과 후 pending 상태면 추가 인증 팝업 자동 오픈
|
||||
var twoFactorPending = [[${twoFactorPending}]];
|
||||
if (twoFactorPending && typeof TwoFactorAuth !== 'undefined') {
|
||||
TwoFactorAuth.open({
|
||||
mode: 'login',
|
||||
onSuccess: function (res) {
|
||||
window.location.href = (res && res.redirect) ? res.redirect : /*[[@{/}]]*/ '/';
|
||||
},
|
||||
onCancel: function (reason) {
|
||||
// 팝업 닫기/타임아웃/실패 = 2차 인증 실패 → 로그인 페이지 유지 후 재로그인 안내
|
||||
if (reason === 'TIMEOUT') {
|
||||
customPopups.showAlert('인증 시간이 초과되어 로그인이 취소되었습니다. 다시 로그인해주세요.');
|
||||
} else if (reason !== 'FAILED') {
|
||||
customPopups.showAlert('추가 인증이 취소되었습니다. 다시 로그인해주세요.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fnInit();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -298,34 +298,56 @@
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
doDeleteApiKey(clientId);
|
||||
});
|
||||
}
|
||||
|
||||
$('.loading-overlay').show();
|
||||
// 인증키 삭제 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
|
||||
function doDeleteApiKey(clientId) {
|
||||
$('.loading-overlay').show();
|
||||
|
||||
const requestData = {
|
||||
clientId: clientId
|
||||
};
|
||||
|
||||
$.ajax({
|
||||
url: '/myapikey/api_key_delete',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(requestData),
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||
}
|
||||
}).done(function (response) {
|
||||
if (response && response.success === false) {
|
||||
customPopups.showAlert(response.msg || 'API 삭제에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function () {
|
||||
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
|
||||
});
|
||||
}).fail(function (jqXHR, textStatus, errorThrown) {
|
||||
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
|
||||
}).always(function () {
|
||||
$('.loading-overlay').hide();
|
||||
$.ajax({
|
||||
url: /*[[@{/myapikey/api_key_delete}]]*/ '/myapikey/api_key_delete',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ clientId: clientId }),
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||
}
|
||||
}).done(function (response) {
|
||||
if (response && response.success === false) {
|
||||
customPopups.showAlert(response.msg || 'API 삭제에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function () {
|
||||
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
|
||||
});
|
||||
}).fail(function (jqXHR, textStatus, errorThrown) {
|
||||
if (isStepUpRequired(jqXHR)) {
|
||||
requireStepUp('/myapikey/api_key_delete', function () { doDeleteApiKey(clientId); });
|
||||
return;
|
||||
}
|
||||
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
|
||||
}).always(function () {
|
||||
$('.loading-overlay').hide();
|
||||
});
|
||||
}
|
||||
|
||||
// step-up 2FA 응답(401 + stepUpRequired) 판별 및 처리 헬퍼
|
||||
function isStepUpRequired(jqXHR) {
|
||||
return jqXHR && jqXHR.status === 401 && jqXHR.responseJSON && jqXHR.responseJSON.stepUpRequired;
|
||||
}
|
||||
|
||||
function requireStepUp(purpose, retry) {
|
||||
if (typeof TwoFactorAuth === 'undefined') {
|
||||
customPopups.showAlert('추가 인증이 필요합니다. 다시 시도해주세요.');
|
||||
return;
|
||||
}
|
||||
TwoFactorAuth.open({
|
||||
mode: 'stepup',
|
||||
purpose: purpose,
|
||||
onSuccess: function () { retry(); },
|
||||
onCancel: function () { /* 취소: 원 작업 중단 */ }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<a class="nav-link" th:href="@{/my_company_info}">기업 정보 변경</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" th:href="@{/password/verify}">비밀번호 변경</a>
|
||||
<a class="nav-link active" th:href="@{/password/change}">비밀번호 변경</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="card mt-2">
|
||||
|
||||
@@ -79,6 +79,35 @@
|
||||
customPopups.showAlert([[${error}]]);
|
||||
})
|
||||
</script>
|
||||
<script th:inline="javascript">
|
||||
// 반영 직전 2FA: twofaRequired 면 제출을 가로채 2FA 팝업 → 성공 시 실제 제출.
|
||||
(function () {
|
||||
var twofaRequired = /*[[${twofaRequired}]]*/ false;
|
||||
if (!twofaRequired) return;
|
||||
var form = document.getElementById('passwordChangeForm');
|
||||
if (!form) return;
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
var np = document.getElementById('newPassword').value;
|
||||
var cp = form.querySelector('input[name="confirmPassword"]').value;
|
||||
if (!np || !cp) { customPopups.showAlert('새 비밀번호를 입력해 주세요.'); return; }
|
||||
if (np !== cp) { customPopups.showAlert('새 비밀번호가 일치하지 않습니다.'); return; }
|
||||
if (window.PasswordPolicy && !PasswordPolicy.isValid(np)) {
|
||||
customPopups.showAlert('비밀번호 규칙을 확인해 주세요.'); return;
|
||||
}
|
||||
|
||||
if (typeof TwoFactorAuth === 'undefined') { form.submit(); return; }
|
||||
TwoFactorAuth.open({
|
||||
purpose: '/password/change',
|
||||
// form.submit() 은 submit 이벤트를 재발생시키지 않으므로 그대로 서버로 전송된다.
|
||||
onSuccess: function () { form.submit(); },
|
||||
onCancel: function () { /* 사용자 취소 — 유지 */ }
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -35,6 +35,29 @@
|
||||
<div id="email-validation" class="org-validation-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이메일 인증 (중복체크 통과 후 노출) -->
|
||||
<div class="org-form-group" id="emailVerifyRow" style="display: none;">
|
||||
<label class="org-form-label">
|
||||
이메일 인증 <span class="required-badge">필수</span>
|
||||
</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<div class="org-input-row">
|
||||
<button type="button" class="btn org-btn-check" id="btnSendEmailCode">인증번호 발송</button>
|
||||
</div>
|
||||
<div class="org-input-row" id="emailCodeRow" style="display: none; margin-top: 8px;">
|
||||
<div class="org-compound-input" style="position: relative; flex: 1;">
|
||||
<input type="text" id="emailAuthCode" class="org-form-input" maxlength="6"
|
||||
inputmode="numeric" autocomplete="one-time-code" placeholder="인증번호 6자리">
|
||||
<span class="org-timer" id="emailCertifyTime"
|
||||
style="position: absolute; right: 12px; top: 50%; transform: translateY(-50%); color: #E11D48;">03:00</span>
|
||||
</div>
|
||||
<button type="button" class="btn org-btn-check" 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>
|
||||
</body>
|
||||
<th:block>
|
||||
@@ -79,6 +102,9 @@
|
||||
newUserForm.show();
|
||||
individualConversionForm.hide();
|
||||
emailChangeForm.hide();
|
||||
|
||||
// 중복체크 통과 → 이메일 인증 UI 노출
|
||||
$('#emailVerifyRow').show();
|
||||
break;
|
||||
|
||||
case "conversionOrChange":
|
||||
@@ -162,6 +188,98 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 이메일 인증 (가입 폼 인라인) =====
|
||||
var emailCodeTimer = null;
|
||||
|
||||
function setEmailAuthMsg(msg, isError) {
|
||||
var el = $('#email-auth-validation');
|
||||
el.text(msg || '');
|
||||
el.css('color', isError ? '#E11D48' : '#0049B4');
|
||||
}
|
||||
|
||||
function startEmailCodeTimer() {
|
||||
clearInterval(emailCodeTimer);
|
||||
var remaining = 180;
|
||||
var el = document.getElementById('emailCertifyTime');
|
||||
$(el).show();
|
||||
function tick() {
|
||||
if (remaining <= 0) {
|
||||
clearInterval(emailCodeTimer);
|
||||
el.textContent = '00:00';
|
||||
setEmailAuthMsg('입력 시간이 초과되었습니다. 인증번호를 다시 발송해주세요.', true);
|
||||
$('#btnSendEmailCode').text('인증번호 재발송').prop('disabled', false);
|
||||
return;
|
||||
}
|
||||
var m = Math.floor(remaining / 60);
|
||||
var s = remaining % 60;
|
||||
el.textContent = (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s);
|
||||
remaining--;
|
||||
}
|
||||
tick();
|
||||
emailCodeTimer = setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
$('#btnSendEmailCode').on('click', function () {
|
||||
var email = $('#loginId').val();
|
||||
if (!email || email.indexOf('@') === -1) {
|
||||
setEmailAuthMsg('이메일을 먼저 확인해주세요.', true);
|
||||
return;
|
||||
}
|
||||
var btn = $(this);
|
||||
btn.prop('disabled', true);
|
||||
$.ajax({
|
||||
url: /*[[@{/signup/email-code/send}]]*/ '/signup/email-code/send',
|
||||
type: 'POST',
|
||||
data: { email: email, _csrf: $('input[name="_csrf"]').val() },
|
||||
success: function (res) {
|
||||
btn.prop('disabled', false);
|
||||
if (res && res.valid) {
|
||||
$('#emailCodeRow').show();
|
||||
$('#emailAuthCode').val('').focus();
|
||||
setEmailAuthMsg('인증번호를 발송했습니다.', false);
|
||||
btn.text('인증번호 재발송');
|
||||
startEmailCodeTimer();
|
||||
} else {
|
||||
setEmailAuthMsg((res && res.message) || '발송에 실패했습니다.', true);
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
btn.prop('disabled', false);
|
||||
setEmailAuthMsg('발송 중 오류가 발생했습니다.', true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#btnVerifyEmailCode').on('click', function () {
|
||||
var email = $('#loginId').val();
|
||||
var code = ($('#emailAuthCode').val() || '').trim();
|
||||
if (!/^[0-9]{6}$/.test(code)) {
|
||||
setEmailAuthMsg('인증번호 6자리를 입력해주세요.', true);
|
||||
return;
|
||||
}
|
||||
$.ajax({
|
||||
url: /*[[@{/signup/email-code/verify}]]*/ '/signup/email-code/verify',
|
||||
type: 'POST',
|
||||
data: { email: email, code: code, _csrf: $('input[name="_csrf"]').val() },
|
||||
success: function (res) {
|
||||
if (res && res.valid) {
|
||||
clearInterval(emailCodeTimer);
|
||||
$('#emailVerified').val('true');
|
||||
$('#emailAuthCode').prop('readonly', true);
|
||||
$('#btnVerifyEmailCode').prop('disabled', true);
|
||||
$('#btnSendEmailCode').prop('disabled', true);
|
||||
$('#emailCertifyTime').hide();
|
||||
setEmailAuthMsg('이메일 인증이 완료되었습니다.', false);
|
||||
} else {
|
||||
setEmailAuthMsg((res && res.message) || '인증번호가 일치하지 않습니다.', true);
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
setEmailAuthMsg('인증 처리 중 오류가 발생했습니다.', true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -27,6 +27,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이메일 인증 (중복체크 통과 후 노출) -->
|
||||
<div class="org-form-group" id="emailVerifyRow" style="display: none;">
|
||||
<label class="org-form-label">
|
||||
이메일 인증 <span class="required-badge">필수</span>
|
||||
</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<div class="org-input-row">
|
||||
<button type="button" class="btn org-btn-check" id="btnSendEmailCode">인증번호 발송</button>
|
||||
</div>
|
||||
<div class="org-input-row" id="emailCodeRow" style="display: none; margin-top: 8px;">
|
||||
<div class="org-compound-input" style="position: relative; flex: 1;">
|
||||
<input type="text" id="emailAuthCode" class="org-form-input" maxlength="6"
|
||||
inputmode="numeric" autocomplete="one-time-code" placeholder="인증번호 6자리">
|
||||
<span class="org-timer" id="emailCertifyTime"
|
||||
style="position: absolute; right: 12px; top: 50%; transform: translateY(-50%); color: #E11D48;">03:00</span>
|
||||
</div>
|
||||
<button type="button" class="btn org-btn-check" 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>
|
||||
|
||||
<!-- Dynamic Form Container -->
|
||||
<div id="dynamicFormContainer">
|
||||
<div id="individualConversionForm" style="display: none;">
|
||||
@@ -97,6 +120,10 @@
|
||||
if (newUserForm) newUserForm.style.display = 'block';
|
||||
if (individualConversionForm) individualConversionForm.style.display = 'none';
|
||||
if (emailChangeForm) emailChangeForm.style.display = 'none';
|
||||
|
||||
// 중복체크 통과 → 이메일 인증 UI 노출
|
||||
var evRow = document.getElementById('emailVerifyRow');
|
||||
if (evRow) evRow.style.display = 'block';
|
||||
break;
|
||||
|
||||
case "conversionOrChange":
|
||||
@@ -212,6 +239,108 @@
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 이메일 인증 (법인 가입 폼 인라인) =====
|
||||
var emailCodeTimer = null;
|
||||
|
||||
function setEmailAuthMsg(msg, isError) {
|
||||
var el = document.getElementById('email-auth-validation');
|
||||
if (!el) return;
|
||||
el.textContent = msg || '';
|
||||
el.style.color = isError ? '#E11D48' : '#0049B4';
|
||||
}
|
||||
|
||||
function startEmailCodeTimer() {
|
||||
clearInterval(emailCodeTimer);
|
||||
var remaining = 180;
|
||||
var el = document.getElementById('emailCertifyTime');
|
||||
if (el) el.style.display = '';
|
||||
function tick() {
|
||||
if (remaining <= 0) {
|
||||
clearInterval(emailCodeTimer);
|
||||
if (el) el.textContent = '00:00';
|
||||
setEmailAuthMsg('입력 시간이 초과되었습니다. 인증번호를 다시 발송해주세요.', true);
|
||||
var sb = document.getElementById('btnSendEmailCode');
|
||||
if (sb) { sb.textContent = '인증번호 재발송'; sb.disabled = false; }
|
||||
return;
|
||||
}
|
||||
var m = Math.floor(remaining / 60);
|
||||
var s = remaining % 60;
|
||||
if (el) el.textContent = (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s);
|
||||
remaining--;
|
||||
}
|
||||
tick();
|
||||
emailCodeTimer = setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
var sendBtn = document.getElementById('btnSendEmailCode');
|
||||
if (sendBtn) {
|
||||
sendBtn.addEventListener('click', function () {
|
||||
var email = document.getElementById('loginId').value;
|
||||
if (!email || email.indexOf('@') === -1) {
|
||||
setEmailAuthMsg('이메일을 먼저 확인해주세요.', true);
|
||||
return;
|
||||
}
|
||||
var btn = this;
|
||||
btn.disabled = true;
|
||||
$.ajax({
|
||||
url: '/signup/email-code/send',
|
||||
type: 'POST',
|
||||
data: { email: email, _csrf: document.querySelector('input[name="_csrf"]')?.value },
|
||||
success: function (res) {
|
||||
btn.disabled = false;
|
||||
if (res && res.valid) {
|
||||
document.getElementById('emailCodeRow').style.display = '';
|
||||
var ci = document.getElementById('emailAuthCode');
|
||||
ci.value = ''; ci.focus();
|
||||
setEmailAuthMsg('인증번호를 발송했습니다.', false);
|
||||
btn.textContent = '인증번호 재발송';
|
||||
startEmailCodeTimer();
|
||||
} else {
|
||||
setEmailAuthMsg((res && res.message) || '발송에 실패했습니다.', true);
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
btn.disabled = false;
|
||||
setEmailAuthMsg('발송 중 오류가 발생했습니다.', true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var verifyBtn = document.getElementById('btnVerifyEmailCode');
|
||||
if (verifyBtn) {
|
||||
verifyBtn.addEventListener('click', function () {
|
||||
var email = document.getElementById('loginId').value;
|
||||
var code = (document.getElementById('emailAuthCode').value || '').trim();
|
||||
if (!/^[0-9]{6}$/.test(code)) {
|
||||
setEmailAuthMsg('인증번호 6자리를 입력해주세요.', true);
|
||||
return;
|
||||
}
|
||||
$.ajax({
|
||||
url: '/signup/email-code/verify',
|
||||
type: 'POST',
|
||||
data: { email: email, code: code, _csrf: document.querySelector('input[name="_csrf"]')?.value },
|
||||
success: function (res) {
|
||||
if (res && res.valid) {
|
||||
clearInterval(emailCodeTimer);
|
||||
document.getElementById('emailVerified').value = 'true';
|
||||
document.getElementById('emailAuthCode').readOnly = true;
|
||||
verifyBtn.disabled = true;
|
||||
document.getElementById('btnSendEmailCode').disabled = true;
|
||||
var t = document.getElementById('emailCertifyTime');
|
||||
if (t) t.style.display = 'none';
|
||||
setEmailAuthMsg('이메일 인증이 완료되었습니다.', false);
|
||||
} else {
|
||||
setEmailAuthMsg((res && res.message) || '인증번호가 일치하지 않습니다.', true);
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
setEmailAuthMsg('인증 처리 중 오류가 발생했습니다.', true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -3,16 +3,6 @@
|
||||
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
|
||||
<body>
|
||||
<th:block th:fragment="headerFragment(headerClass)">
|
||||
<!-- Design Survey Bar -->
|
||||
<div th:if="${designSurveyEnabled}" class="design-survey-bar" id="designSurveyBar">
|
||||
<div class="survey-container">
|
||||
<span class="survey-label">네비게이션 디자인을 선택해주세요:</span>
|
||||
<div class="survey-buttons" id="surveyButtons">
|
||||
<!-- 버튼은 JavaScript에서 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Header Container -->
|
||||
<header class="global-header" th:classappend="${headerClass}">
|
||||
<div class="container">
|
||||
@@ -97,7 +87,7 @@
|
||||
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
|
||||
</li>
|
||||
<li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li>
|
||||
<li><a th:href="@{/password/verify}"><i class="fas fa-lock"></i>비밀번호 변경</a></li>
|
||||
<li><a th:href="@{/password/change}"><i class="fas fa-lock"></i>비밀번호 변경</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,7 +243,7 @@
|
||||
<li sec:authorize="hasRole('ROLE_WEBHOOK')"><a th:href="@{/webhook}">Webhook 관리</a></li>
|
||||
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li>
|
||||
<li><a th:href="@{/mypage}">내 정보 관리</a></li>
|
||||
<li><a th:href="@{/password/verify}">비밀번호 변경</a></li>
|
||||
<li><a th:href="@{/password/change}">비밀번호 변경</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -579,107 +569,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Design Survey Configuration
|
||||
// 새 디자인 옵션 추가: DESIGN_OPTIONS 배열에 항목 추가
|
||||
// 예: { id: 'D', label: 'D', styles: { '.logo-text': { 'font-size': '16px' } } }
|
||||
// ============================================================
|
||||
const DESIGN_OPTIONS = [
|
||||
{
|
||||
id: 'A',
|
||||
label: 'A (현재)',
|
||||
isDefault: true,
|
||||
styles: {} // 기본 스타일 (변경 없음)
|
||||
},
|
||||
{
|
||||
id: 'B',
|
||||
label: 'B',
|
||||
styles: {
|
||||
'.logo-text': { 'font-size': '18px' },
|
||||
'.nav-link': { 'margin': '0 20px' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'C',
|
||||
label: 'C',
|
||||
styles: {
|
||||
'.nav-link': { 'margin': '0 20px', 'font-weight': 'bold' }
|
||||
}
|
||||
}
|
||||
// 새 디자인 추가 예시:
|
||||
// {
|
||||
// id: 'D',
|
||||
// label: 'D',
|
||||
// styles: {
|
||||
// '.logo-text': { 'font-size': '16px', 'color': '#333' },
|
||||
// '.nav-link': { 'padding': '10px 24px' }
|
||||
// }
|
||||
// }
|
||||
];
|
||||
|
||||
// Design Survey Logic
|
||||
const surveyBar = document.getElementById('designSurveyBar');
|
||||
if (surveyBar) {
|
||||
const body = document.body;
|
||||
const buttonContainer = document.getElementById('surveyButtons');
|
||||
const STORAGE_KEY = 'design-survey-selection';
|
||||
let styleElement = null;
|
||||
|
||||
body.classList.add('design-survey-active');
|
||||
|
||||
// 버튼 동적 생성
|
||||
DESIGN_OPTIONS.forEach(option => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'survey-btn';
|
||||
btn.dataset.design = option.id;
|
||||
btn.textContent = option.label;
|
||||
buttonContainer.appendChild(btn);
|
||||
});
|
||||
|
||||
const surveyButtons = buttonContainer.querySelectorAll('.survey-btn');
|
||||
const defaultOption = DESIGN_OPTIONS.find(o => o.isDefault) || DESIGN_OPTIONS[0];
|
||||
const savedDesign = localStorage.getItem(STORAGE_KEY) || defaultOption.id;
|
||||
|
||||
applyDesign(savedDesign);
|
||||
surveyButtons.forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.design === savedDesign);
|
||||
});
|
||||
|
||||
surveyButtons.forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
surveyButtons.forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
applyDesign(this.dataset.design);
|
||||
localStorage.setItem(STORAGE_KEY, this.dataset.design);
|
||||
});
|
||||
});
|
||||
|
||||
function applyDesign(designId) {
|
||||
// 기존 동적 스타일 제거
|
||||
if (styleElement) {
|
||||
styleElement.remove();
|
||||
styleElement = null;
|
||||
}
|
||||
|
||||
const option = DESIGN_OPTIONS.find(o => o.id === designId);
|
||||
if (!option || Object.keys(option.styles).length === 0) return;
|
||||
|
||||
// 동적 스타일 생성
|
||||
let css = '';
|
||||
for (const [selector, props] of Object.entries(option.styles)) {
|
||||
const propsStr = Object.entries(props)
|
||||
.map(([prop, val]) => `${prop}: ${val} !important`)
|
||||
.join('; ');
|
||||
css += `${selector} { ${propsStr}; }\n`;
|
||||
}
|
||||
|
||||
styleElement = document.createElement('style');
|
||||
styleElement.id = 'design-survey-styles';
|
||||
styleElement.textContent = css;
|
||||
document.head.appendChild(styleElement);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
th:classappend="${activeMenu == 'profile'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">내정보 관리</a>
|
||||
|
||||
<a th:href="@{/password/verify}"
|
||||
<a th:href="@{/password/change}"
|
||||
th:classappend="${activeMenu == 'password'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">비밀번호 변경</a>
|
||||
</th:block>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<!-- views/fragment/popup/twoFactorAuthPopup.html : 공통 2FA(추가 인증) 팝업 -->
|
||||
<div th:fragment="twoFactorAuthPopup" id="tfaPopup" style="display: none;" xmlns:th="http://www.thymeleaf.org">
|
||||
<div class="modal-backdrop" id="tfaBackdrop"></div>
|
||||
|
||||
<div class="modal" id="tfaModal">
|
||||
<div class="modal-dialog tfa-card">
|
||||
|
||||
<button type="button" class="tfa-close" id="tfaCloseButton" aria-label="닫기">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- 헤더 -->
|
||||
<div class="tfa-head">
|
||||
<span class="tfa-head-title" id="tfaTitle">추가 인증</span>
|
||||
<span class="tfa-head-brand">DJBank</span>
|
||||
</div>
|
||||
|
||||
<div class="tfa-body">
|
||||
|
||||
<!-- STEP 1 : 인증 수단 -->
|
||||
<div class="tfa-step">
|
||||
<span class="tfa-step-num is-active">1</span>
|
||||
<span class="tfa-step-text is-active">인증 수단</span>
|
||||
</div>
|
||||
<!-- 채널 선택(이메일/휴대폰) — JS 가 렌더링 -->
|
||||
<div class="tfa-segment" id="tfaSegment" role="tablist"></div>
|
||||
|
||||
<!-- STEP 2 : 인증번호 입력 -->
|
||||
<div class="tfa-step">
|
||||
<span class="tfa-step-num" id="tfaStepNum">2</span>
|
||||
<span class="tfa-step-text" id="tfaStepText">인증번호 입력</span>
|
||||
</div>
|
||||
|
||||
<!-- 인증번호 발송 전 -->
|
||||
<div id="tfaSendStep">
|
||||
<div class="tfa-hint">아래 버튼을 누르면 선택하신 수단으로 6자리 인증번호를 보내드립니다.</div>
|
||||
<button type="button" class="tfa-cta" id="tfaSendButton">인증번호 받기</button>
|
||||
</div>
|
||||
|
||||
<!-- 인증번호 발송 후 -->
|
||||
<div id="tfaVerifyStep" style="display: none;">
|
||||
<div class="tfa-code-row">
|
||||
<div class="tfa-code-input-wrap">
|
||||
<input type="text" id="tfaCodeInput" class="tfa-code-input" inputmode="numeric" maxlength="6"
|
||||
autocomplete="one-time-code" placeholder="인증번호 6자리"/>
|
||||
</div>
|
||||
<button type="button" class="tfa-resend" id="tfaResendButton">재전송</button>
|
||||
</div>
|
||||
|
||||
<div class="tfa-timer-row">
|
||||
<span class="tfa-timer-label" id="tfaTimerLabel">남은 인증 시간</span>
|
||||
<span class="tfa-timer-clock">
|
||||
<span class="tfa-timer" id="tfaTimer">3:00</span>
|
||||
<span class="tfa-timer-total" id="tfaTimerTotal">/ 3:00</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="tfa-progress"><div class="tfa-progress-bar" id="tfaProgressBar"></div></div>
|
||||
|
||||
<p class="tfa-message" id="tfaMessage"></p>
|
||||
<div class="tfa-test-notice" id="tfaTestNotice" style="display: none;"></div>
|
||||
|
||||
<button type="button" class="tfa-cta" id="tfaVerifyButton" disabled>인증 완료</button>
|
||||
</div>
|
||||
|
||||
<div class="tfa-foot">인증번호가 오지 않으면 스팸함을 확인하시거나 다른 수단으로 다시 시도해 주세요.</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -22,7 +22,9 @@
|
||||
|
||||
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
|
||||
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
|
||||
<section th:replace="fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup"></section>
|
||||
<section layout:fragment="pagePopups"></section>
|
||||
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
||||
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
|
||||
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
|
||||
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
|
||||
<section th:replace="fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup"></section>
|
||||
<section layout:fragment="pagePopups"></section>
|
||||
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
||||
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user