feats/security → design 병합: 2FA·Step-up 인증 통합

- SuccessHandler 충돌 해결: LoginFinalizer 리팩터 버전 채택
- LoginFinalizer 로그 마스킹 재적용(design 변경 보존)
- CSS 생성물 충돌은 SASS 재컴파일로 해소(.tfa- 포함)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rinjae
2026-07-28 09:55:57 +09:00
43 changed files with 3174 additions and 29073 deletions
@@ -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,90 @@
package com.eactive.apim.portal.apps.auth.twofactor;
import com.eactive.apim.portal.config.PasswordChangeEnforcementInterceptor;
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}) 진입 시, 유효한 1회용 통과권이 없으면 인증을 요구한다.
* <ul>
* <li>GET(페이지 진입) → {@code /auth/2fa/challenge} 로 리다이렉트(원경로는 returnUrl 로 보존)</li>
* <li>POST(AJAX: Secret 조회/앱 해지) → {@code 401 + {"stepUpRequired":true}} JSON</li>
* </ul>
* "매번 인증" 정책이므로 통과권은 {@code consumeStepUpPass} 에서 즉시 소멸한다.</p>
*
* <p>비밀번호 강제 변경 상태(pwEnforce/passwordExpired)의 {@code /password/*} 는 제외한다
* (강제 변경 유도 경로 — {@code PasswordChangeEnforcementInterceptor} 가 이미 관장).</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.isProtected(path)) {
return true;
}
// 지점별 스위치가 꺼져 있으면 해당 경로는 step-up 미적용
if (!twoFactorProperties.isStepUpPointEnabled(path)) {
return true;
}
HttpSession session = request.getSession(false);
if (session == null) {
// 세션(=인증)이 없으면 여기서 다루지 않고 보안 계층(@Secured)에 맡긴다.
return true;
}
// 비밀번호 강제 변경 상태의 /password/* 는 step-up 제외
if (StepUpProtectedPaths.isPasswordPath(path) && isPasswordEnforced(session)) {
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;
}
private boolean isPasswordEnforced(HttpSession session) {
return Boolean.TRUE.equals(session.getAttribute(PasswordChangeEnforcementInterceptor.ENFORCE_SESSION_ATTR))
|| Boolean.TRUE.equals(session.getAttribute("passwordExpired"));
}
}
@@ -0,0 +1,72 @@
package com.eactive.apim.portal.apps.auth.twofactor;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* step-up 2FA 보호 대상 서블릿 경로(화이트리스트) + 지점별 프로퍼티 키 매핑.
*
* <p>인터셉터(진입 차단)와 서비스(통과권 발급 시 대상 검증)가 동일 목록을 공유한다.
* open redirect / 임의 경로 통과권 발급을 막기 위해 반드시 이 집합으로 검증한다.</p>
*
* <p>지점별 활성화는 PTL_PROPERTY 키({@code two-factor.stepup.<지점>})로 개별 제어한다.
* 전체 스위치 {@code two-factor.stepup.enabled} 와 AND 로 동작한다.</p>
*
* <p>{@code /new_password} 계열(비밀번호 강제 변경 유도)은 제외 대상이므로 여기 없음.
* 강제 접속(세션 pwEnforce/passwordExpired) 시 /password/* 도 인터셉터에서 별도 제외한다.</p>
*/
public final class StepUpProtectedPaths {
/** 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, 정확 일치) */
public static final String MYPAGE = "/mypage";
/** 비밀번호 변경 진입 - 현재비번 확인 (GET) */
public static final String PASSWORD_VERIFY = "/password/verify";
/** 비밀번호 변경 폼 (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;
static {
Map<String, String> m = new LinkedHashMap<>();
// 비밀번호 변경은 verify/change 두 진입이 한 기능이므로 동일 키 공유
m.put(REVEAL_SECRET, KEY_PREFIX + "reveal-secret");
m.put(APP_MODIFY_STEP1, KEY_PREFIX + "app-modify");
m.put(APP_KEY_DELETE, KEY_PREFIX + "app-delete");
m.put(MYPAGE, KEY_PREFIX + "mypage");
m.put(PASSWORD_VERIFY, KEY_PREFIX + "password-change");
m.put(PASSWORD_CHANGE, KEY_PREFIX + "password-change");
PATH_TO_KEY = Collections.unmodifiableMap(m);
}
private StepUpProtectedPaths() {
}
public static boolean isProtected(String servletPath) {
return servletPath != null && PATH_TO_KEY.containsKey(servletPath);
}
/** 해당 경로의 지점별 활성화 프로퍼티 키. 보호 경로가 아니면 null */
public static String propertyKeyOf(String servletPath) {
return servletPath == null ? null : PATH_TO_KEY.get(servletPath);
}
/** 지점별 프로퍼티 키 접두 */
public static String keyPrefix() {
return KEY_PREFIX;
}
/** 비밀번호 강제 변경 상태(pwEnforce/passwordExpired)에서 step-up 을 건너뛸 경로인지 */
public static boolean isPasswordPath(String servletPath) {
return PASSWORD_VERIFY.equals(servletPath) || PASSWORD_CHANGE.equals(servletPath);
}
}
@@ -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.isProtected(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,493 @@
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.isProtected(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());
}
/**
* 지정 경로에 대한 유효한 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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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,260 @@
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");
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";
}
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/verify");
}
}
/**
* 비밀번호 변경 대상자에게 정책(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());
}
}
@@ -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);
@@ -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;
@@ -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");
@@ -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
+324 -69
View File
@@ -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;
@@ -7634,6 +7565,330 @@ button.djb-comment-submit:disabled {
color: #888;
}
.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%;
File diff suppressed because one or more lines are too long
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;
+1
View File
@@ -45,6 +45,7 @@
@use 'components/test-env-notice' as *;
@use 'components/djb-inquiry-comments' as *;
@use 'components/password-policy' as *;
@use 'components/two-factor' as *;
// 5. Page-specific styles
@use 'pages/index' as *;
@@ -0,0 +1,135 @@
<!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 {
padding: 40px 16px 48px; /* 상단 약간의 여백만 */
}
.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>
@@ -248,6 +248,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>
@@ -197,39 +197,30 @@
);
}
// 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() { /* 취소: 원 작업 중단 */ }
});
}
// Show password prompt for viewing client secret (최초 1회 노출 + 서버측 물리 삭제)
function showPasswordPrompt() {
customPopups.showPasswordInput({
title: 'Client Secret 조회',
message: '보안을 위해 비밀번호를 입력해주세요.<br>조회 즉시 값은 영구 삭제됩니다.',
onConfirm: function(password) {
$.ajax({
url: /*[[@{/myapikey/credential/reveal-secret}]]*/ '/myapikey/credential/reveal-secret',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID, password: password }),
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
}
}).done(function(response) {
if (response.success) {
customPopups.hidePasswordInput();
// 서버가 반환한 secret을 화면에 1회 주입
$('#revealedSecretValue').text(response.secret);
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
$('#hiddenSecretBox').hide();
$('#revealedSecretBox').fadeIn(300);
} else if (response.alreadyRevealed) {
customPopups.hidePasswordInput();
showLostKeyGuide();
} else {
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
}
}).fail(function() {
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
});
doRevealSecret(password);
},
onCancel: function() {
// User cancelled - do nothing
@@ -237,6 +228,42 @@
});
}
// Client Secret 조회 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
function doRevealSecret(password) {
$.ajax({
url: /*[[@{/myapikey/credential/reveal-secret}]]*/ '/myapikey/credential/reveal-secret',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID, password: password }),
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
}
}).done(function(response) {
if (response.success) {
customPopups.hidePasswordInput();
// 서버가 반환한 secret을 화면에 1회 주입
$('#revealedSecretValue').text(response.secret);
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
$('#hiddenSecretBox').hide();
$('#revealedSecretBox').fadeIn(300);
} else if (response.alreadyRevealed) {
customPopups.hidePasswordInput();
showLostKeyGuide();
} else {
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
}
}).fail(function(jqXHR) {
if (isStepUpRequired(jqXHR)) {
customPopups.hidePasswordInput();
requireStepUp('/myapikey/credential/reveal-secret', function() { doRevealSecret(password); });
return;
}
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
});
}
// Copy to clipboard function - called from button with data-secret attribute
function copyToClipboardFromButton(button) {
var text = $(button).data('secret');
@@ -277,34 +304,38 @@
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',
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();
});
}
@@ -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">
@@ -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>
@@ -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>