Implement SMS two-factor authentication with AJAX support

This commit is contained in:
Rinjae
2026-01-15 11:29:58 +09:00
parent b4e149251f
commit c4ae6ce4e8
7 changed files with 967 additions and 17 deletions
@@ -74,6 +74,8 @@ public class MonitoringFrontFilter implements Filter {
|| uri.indexOf(request.getContextPath() +"/dashmain3.do") > -1
|| uri.indexOf(request.getContextPath() + "/loginForm.do") > -1
|| uri.indexOf(request.getContextPath() + "/login.do") > -1
|| uri.indexOf(request.getContextPath() + "/login-ajax.do") > -1
|| uri.indexOf(request.getContextPath() + "/sms-auth/") > -1
|| uri.indexOf(request.getContextPath() + "/ssoLogin.do") > -1
|| uri.indexOf(request.getContextPath() + "/changePassword.do") > -1
|| uri.indexOf(request.getContextPath() + "/dashboard") > -1
@@ -0,0 +1,48 @@
package com.eactive.eai.rms.common.login;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponseDto {
/**
* 로그인 성공 여부
*/
private boolean success;
/**
* SMS 2차 인증 필요 여부
*/
private boolean smsAuthRequired;
/**
* 마스킹된 전화번호 (예: 010****1234)
*/
private String maskedPhone;
/**
* 인증번호 만료 시각 (timestamp, 밀리초)
*/
private long expiresAt;
/**
* 재발급 가능 시각 (timestamp, 밀리초)
*/
private long resendableAt;
/**
* 리다이렉트 URL (로그인 성공 시)
*/
private String redirectUrl;
/**
* 에러 메시지 (로그인 실패 시)
*/
private String errorMessage;
}
@@ -14,6 +14,7 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.naming.Context;
import org.apache.commons.lang3.StringUtils;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import javax.servlet.ServletOutputStream;
@@ -27,9 +28,13 @@ import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.View;
import com.eactive.eai.common.seed.Seed;
import com.eactive.eai.rms.ext.kjb.smsauth.SmsAuthService;
import com.eactive.ext.kjb.common.KjbProperty;
import com.eactive.ext.kjb.util.KjbPropertyInjector;
import com.eactive.eai.data.converter.LocalDateTimeFormatters;
import com.eactive.eai.rms.common.context.MonitoringContext;
import com.eactive.eai.rms.common.datasource.DataSourceType;
@@ -92,8 +97,18 @@ public class MainController implements InterceptorSkipController {
@RequestMapping(value = "/loginForm.do")
public String loginForm(HttpServletRequest request, Model model) {
boolean fullScreen = request.getParameter("fullScreen") != null;
boolean emergencyMode = request.getParameter("emergency") != null;
model.addAttribute("fullScreen", fullScreen);
model.addAttribute("emergencyMode", emergencyMode);
// 세션에 비상모드 저장 (SMS 인증 우회용)
HttpSession session = request.getSession();
session.setAttribute("emergencyMode", emergencyMode);
if (emergencyMode) {
logger.info("비상 로그인 모드 활성화 - SSO/SMS 인증 우회");
}
return "/common/screen/emergency";
}
@@ -103,9 +118,9 @@ public class MainController implements InterceptorSkipController {
if (dto == null) {
dto = new LoginVo();
}
HttpSession session = request.getSession();
try {
initializeLoginAndSetDashboardImage(session);
@@ -120,6 +135,150 @@ public class MainController implements InterceptorSkipController {
}
/**
* AJAX 로그인 엔드포인트 (SMS 2차 인증 대응)
*/
@RequestMapping("/login-ajax.do")
@ResponseBody
public LoginResponseDto preMainAjax(HttpServletRequest request, LoginVo dto) {
if (dto == null) {
dto = new LoginVo();
}
HttpSession session = request.getSession();
try {
initializeLoginAndSetDashboardImage(session);
// 1. 비밀번호 인증
UserInfo userInfo = authenticateAndVerifyUser(request, session, dto);
if (userInfo == null) {
String errorMsg = (String) session.getAttribute(RESULT_MSG);
session.removeAttribute(RESULT_MSG);
return LoginResponseDto.builder()
.success(false)
.errorMessage(errorMsg != null ? errorMsg : "로그인에 실패했습니다.")
.build();
}
// 2. 비상모드 체크 - SMS 인증 우회
Boolean emergencyMode = (Boolean) session.getAttribute("emergencyMode");
if (Boolean.TRUE.equals(emergencyMode)) {
logger.info("비상 로그인 모드 - SMS 인증 우회 - userId: {}", userInfo.getUserid());
session.removeAttribute("emergencyMode"); // 사용 후 제거
// 바로 로그인 완료로 이동
String result = loadUserInfoAndSetupSession(request, session, dto, userInfo);
String contextPath = request.getContextPath();
String redirectUrl = contextPath + "/main.do";
if (result != null && result.startsWith("redirect:")) {
String path = result.substring("redirect:".length());
if (!path.startsWith(contextPath)) {
redirectUrl = contextPath + path;
} else {
redirectUrl = path;
}
}
String serviceType = request.getParameter("serviceType");
if (serviceType != null && !redirectUrl.contains("serviceType")) {
redirectUrl += (redirectUrl.contains("?") ? "&" : "?") + "serviceType=" + serviceType;
}
return LoginResponseDto.builder()
.success(true)
.smsAuthRequired(false)
.redirectUrl(redirectUrl)
.build();
}
// 3. SMS 인증 필요 여부 체크
SmsAuthService smsAuthService = getSmsAuthService();
if (smsAuthService != null && smsAuthService.isEnabled()) {
// SMS 인증 활성화 상태
if (!smsAuthService.hasValidPhoneNumber(userInfo)) {
// 휴대폰번호 없는 사용자
return LoginResponseDto.builder()
.success(false)
.errorMessage("휴대폰번호가 등록되어 있지 않아 로그인할 수 없습니다. 관리자에게 문의하세요.")
.build();
}
// SMS 인증번호 생성 및 발송
String authCode = smsAuthService.generateAuthCode();
boolean sent = smsAuthService.sendAuthCode(userInfo.getCphnno(), authCode);
if (!sent) {
return LoginResponseDto.builder()
.success(false)
.errorMessage("인증번호 발송에 실패했습니다. 잠시 후 다시 시도해주세요.")
.build();
}
// 세션에 SMS 인증 정보 저장
smsAuthService.setupAuthSession(session, userInfo, dto, authCode);
// serviceType 세션에 저장
String serviceType = request.getParameter("serviceType");
if (serviceType != null) {
session.setAttribute("serviceType", serviceType);
}
return LoginResponseDto.builder()
.success(true)
.smsAuthRequired(true)
.maskedPhone(smsAuthService.maskPhoneNumber(userInfo.getCphnno()))
.expiresAt(smsAuthService.getExpiresAt(session))
.resendableAt(smsAuthService.getResendableAt(session))
.build();
}
// 3. SMS 인증 비활성화 또는 서비스 없음 - 바로 로그인 완료
String result = loadUserInfoAndSetupSession(request, session, dto, userInfo);
String contextPath = request.getContextPath();
String redirectUrl = contextPath + "/main.do";
if (result != null && result.startsWith("redirect:")) {
String path = result.substring("redirect:".length());
if (!path.startsWith(contextPath)) {
redirectUrl = contextPath + path;
} else {
redirectUrl = path;
}
}
// serviceType 파라미터 추가
String serviceType = request.getParameter("serviceType");
if (serviceType != null && !redirectUrl.contains("serviceType")) {
redirectUrl += (redirectUrl.contains("?") ? "&" : "?") + "serviceType=" + serviceType;
}
return LoginResponseDto.builder()
.success(true)
.smsAuthRequired(false)
.redirectUrl(redirectUrl)
.build();
} catch (Exception e) {
logger.error("AJAX 로그인 처리 중 오류", e);
return LoginResponseDto.builder()
.success(false)
.errorMessage("로그인 처리 중 오류가 발생했습니다.")
.build();
}
}
/**
* SmsAuthService 인스턴스 조회 (lazy initialization)
*/
private SmsAuthService getSmsAuthService() {
try {
KjbPropertyInjector injector = new KjbPropertyInjector(monitoringPropertyService, "Monitoring");
KjbProperty kjbProperty = injector.inject(new KjbProperty());
return new SmsAuthService(kjbProperty);
} catch (Exception e) {
logger.warn("SmsAuthService 초기화 실패 - SMS 인증 비활성화 처리", e);
return null;
}
}
/**
* SSO 로그인용 세션 설정 (SsoController에서 호출)
* 비밀번호 인증 없이 UserInfo만으로 로그인 처리
@@ -286,9 +445,12 @@ public class MainController implements InterceptorSkipController {
}
}
// 사용자 정보 로드 및 세션 설정
private String loadUserInfoAndSetupSession(HttpServletRequest request,
HttpSession session, LoginVo dto, UserInfo userInfo)
/**
* 사용자 정보 로드 및 세션 설정
* public으로 변경하여 SmsAuthController에서 호출 가능
*/
public String loadUserInfoAndSetupSession(HttpServletRequest request,
HttpSession session, LoginVo dto, UserInfo userInfo)
throws Exception {
// LDAP 사용시 주석
@@ -0,0 +1,255 @@
package com.eactive.eai.rms.ext.kjb.smsauth;
import com.eactive.apim.portal.user.entity.UserInfo;
import com.eactive.eai.rms.common.interceptor.InterceptorSkipController;
import com.eactive.eai.rms.common.login.LoginVo;
import com.eactive.eai.rms.common.login.MainController;
import com.eactive.eai.rms.common.util.CommonConstants;
import com.eactive.eai.rms.data.entity.man.monitoringProperty.service.MonitoringPropertyService;
import com.eactive.ext.kjb.common.KjbProperty;
import com.eactive.ext.kjb.util.KjbPropertyInjector;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
@Slf4j
@Controller
@RequestMapping("/sms-auth")
public class SmsAuthController implements InterceptorSkipController {
public static final String PROP_GROUP_ID = "Monitoring";
private final MainController mainController;
private final KjbPropertyInjector kjbPropertyInjector;
private SmsAuthService smsAuthService;
private KjbProperty kjbProperty;
public SmsAuthController(MonitoringPropertyService monitoringPropertyService,
MainController mainController) {
this.mainController = mainController;
this.kjbPropertyInjector = new KjbPropertyInjector(monitoringPropertyService, PROP_GROUP_ID);
reloadProperty();
}
/**
* 프로퍼티 리로드 (DB 변경 대응)
*/
private void reloadProperty() {
this.kjbProperty = kjbPropertyInjector.inject(new KjbProperty());
this.smsAuthService = new SmsAuthService(kjbProperty);
}
/**
* SMS 인증번호 발송
*/
@RequestMapping(value = "/send.json", method = RequestMethod.POST)
@ResponseBody
public SmsAuthDto sendAuthCode(HttpServletRequest request) {
// 프로퍼티 리로드 (DB 변경 대응)
reloadProperty();
HttpSession session = request.getSession();
UserInfo userInfo = smsAuthService.getUserInfoFromSession(session);
if (userInfo == null) {
log.warn("SMS 인증 세션 정보 없음 - send 요청");
return SmsAuthDto.builder()
.sent(false)
.errorMessage("인증 세션이 만료되었습니다. 다시 로그인해주세요.")
.build();
}
if (!smsAuthService.hasValidPhoneNumber(userInfo)) {
log.warn("유효한 휴대폰번호 없음 - userId: {}", userInfo.getUserid());
return SmsAuthDto.builder()
.sent(false)
.errorMessage("휴대폰번호가 등록되어 있지 않습니다. 관리자에게 문의하세요.")
.build();
}
// 인증번호 생성 발송
String authCode = smsAuthService.generateAuthCode();
boolean sent = smsAuthService.sendAuthCode(userInfo.getCphnno(), authCode);
if (sent) {
// 세션에 인증번호 저장 (기존 UserInfo, LoginVo 유지)
LoginVo loginVo = smsAuthService.getLoginVoFromSession(session);
smsAuthService.setupAuthSession(session, userInfo, loginVo, authCode);
return SmsAuthDto.builder()
.sent(true)
.maskedPhone(smsAuthService.maskPhoneNumber(userInfo.getCphnno()))
.expiresAt(smsAuthService.getExpiresAt(session))
.resendableAt(smsAuthService.getResendableAt(session))
.build();
} else {
return SmsAuthDto.builder()
.sent(false)
.errorMessage("인증번호 발송에 실패했습니다. 잠시 후 다시 시도해주세요.")
.build();
}
}
/**
* SMS 인증번호 재발송
*/
@RequestMapping(value = "/resend.json", method = RequestMethod.POST)
@ResponseBody
public SmsAuthDto resendAuthCode(HttpServletRequest request) {
// 프로퍼티 리로드 (DB 변경 대응)
reloadProperty();
HttpSession session = request.getSession();
UserInfo userInfo = smsAuthService.getUserInfoFromSession(session);
if (userInfo == null) {
log.warn("SMS 인증 세션 정보 없음 - resend 요청");
return SmsAuthDto.builder()
.sent(false)
.errorMessage("인증 세션이 만료되었습니다. 다시 로그인해주세요.")
.build();
}
if (!smsAuthService.canResend(session)) {
long waitTime = smsAuthService.getResendWaitTime(session);
log.debug("재발송 대기 중 - 남은 시간: {}ms", waitTime);
return SmsAuthDto.builder()
.sent(false)
.errorMessage(String.format("%.0f초 후에 재발송이 가능합니다.", Math.ceil(waitTime / 1000.0)))
.resendableAt(smsAuthService.getResendableAt(session))
.build();
}
// 인증번호 생성 발송
String authCode = smsAuthService.generateAuthCode();
boolean sent = smsAuthService.sendAuthCode(userInfo.getCphnno(), authCode);
if (sent) {
LoginVo loginVo = smsAuthService.getLoginVoFromSession(session);
smsAuthService.setupAuthSession(session, userInfo, loginVo, authCode);
return SmsAuthDto.builder()
.sent(true)
.maskedPhone(smsAuthService.maskPhoneNumber(userInfo.getCphnno()))
.expiresAt(smsAuthService.getExpiresAt(session))
.resendableAt(smsAuthService.getResendableAt(session))
.build();
} else {
return SmsAuthDto.builder()
.sent(false)
.errorMessage("인증번호 발송에 실패했습니다. 잠시 후 다시 시도해주세요.")
.build();
}
}
/**
* SMS 인증번호 검증 로그인 완료
*/
@RequestMapping(value = "/verify.json", method = RequestMethod.POST)
@ResponseBody
public SmsAuthDto verifyAuthCode(HttpServletRequest request,
@RequestParam("authCode") String authCode) {
HttpSession session = request.getSession();
UserInfo userInfo = smsAuthService.getUserInfoFromSession(session);
LoginVo loginVo = smsAuthService.getLoginVoFromSession(session);
if (userInfo == null || loginVo == null) {
log.warn("SMS 인증 세션 정보 없음 - verify 요청");
return SmsAuthDto.builder()
.success(false)
.errorMessage("인증 세션이 만료되었습니다. 다시 로그인해주세요.")
.build();
}
if (smsAuthService.isExpired(session)) {
log.warn("SMS 인증번호 만료됨 - userId: {}", userInfo.getUserid());
smsAuthService.clearAuthSession(session);
return SmsAuthDto.builder()
.success(false)
.errorMessage("인증번호가 만료되었습니다. 다시 시도해주세요.")
.build();
}
if (!smsAuthService.validateAuthCode(session, authCode)) {
log.warn("SMS 인증번호 불일치 - userId: {}", userInfo.getUserid());
return SmsAuthDto.builder()
.success(false)
.errorMessage("인증번호가 일치하지 않습니다.")
.build();
}
// 인증 성공 - 로그인 완료 처리
try {
smsAuthService.clearAuthSession(session);
// MainController의 loadUserInfoAndSetupSession 호출
String result = mainController.loadUserInfoAndSetupSession(request, session, loginVo, userInfo);
// result가 "redirect:/main.do" 형태이면 redirectUrl 추출
String contextPath = request.getContextPath();
String redirectUrl = contextPath + "/main.do";
if (result != null && result.startsWith("redirect:")) {
String path = result.substring("redirect:".length());
// 이미 contextPath가 포함되어 있지 않으면 추가
if (!path.startsWith(contextPath)) {
redirectUrl = contextPath + path;
} else {
redirectUrl = path;
}
}
// serviceType 파라미터 추가
String serviceType = request.getParameter("serviceType");
if (serviceType == null) {
serviceType = (String) session.getAttribute("serviceType");
}
if (serviceType != null && !redirectUrl.contains("serviceType")) {
redirectUrl += (redirectUrl.contains("?") ? "&" : "?") + "serviceType=" + serviceType;
}
log.info("SMS 인증 성공 - 로그인 완료 - userId: {}", userInfo.getUserid());
return SmsAuthDto.builder()
.success(true)
.redirectUrl(redirectUrl)
.build();
} catch (Exception e) {
log.error("SMS 인증 후 로그인 처리 실패", e);
return SmsAuthDto.builder()
.success(false)
.errorMessage("로그인 처리 중 오류가 발생했습니다.")
.build();
}
}
/**
* SMS 인증 상태 조회
*/
@RequestMapping(value = "/status.json", method = RequestMethod.GET)
@ResponseBody
public SmsAuthDto getStatus(HttpServletRequest request) {
HttpSession session = request.getSession();
UserInfo userInfo = smsAuthService.getUserInfoFromSession(session);
if (userInfo == null) {
return SmsAuthDto.builder()
.enabled(false)
.errorMessage("인증 세션이 없습니다.")
.build();
}
return SmsAuthDto.builder()
.enabled(smsAuthService.isEnabled())
.maskedPhone(smsAuthService.maskPhoneNumber(userInfo.getCphnno()))
.expiresAt(smsAuthService.getExpiresAt(session))
.resendableAt(smsAuthService.getResendableAt(session))
.build();
}
}
@@ -0,0 +1,53 @@
package com.eactive.eai.rms.ext.kjb.smsauth;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SmsAuthDto {
/**
* SMS 인증 활성화 여부
*/
private boolean enabled;
/**
* 발송 성공 여부
*/
private boolean sent;
/**
* 인증 성공 여부
*/
private boolean success;
/**
* 마스킹된 전화번호 (: 010****1234)
*/
private String maskedPhone;
/**
* 만료 시각 (timestamp, 밀리초)
*/
private long expiresAt;
/**
* 재발급 가능 시각 (timestamp, 밀리초)
*/
private long resendableAt;
/**
* 리다이렉트 URL (인증 성공 )
*/
private String redirectUrl;
/**
* 에러 메시지
*/
private String errorMessage;
}
@@ -0,0 +1,270 @@
package com.eactive.eai.rms.ext.kjb.smsauth;
import com.eactive.apim.portal.user.entity.UserInfo;
import com.eactive.eai.rms.common.login.LoginVo;
import com.eactive.ext.kjb.common.KjbProperty;
import com.eactive.ext.kjb.common.KjbUmsException;
import com.eactive.ext.kjb.ums.KjbUmsService;
import com.eactive.ext.kjb.ums.UmsBizWorkCode;
import com.eactive.ext.kjb.ums.gson.VerifyPhoneUmsTemplate;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import javax.servlet.http.HttpSession;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Slf4j
public class SmsAuthService {
// 세션
public static final String SESSION_KEY_AUTH_CODE = "SMS_AUTH_CODE";
public static final String SESSION_KEY_ISSUED_AT = "SMS_AUTH_ISSUED_AT";
public static final String SESSION_KEY_USER_INFO = "SMS_AUTH_USER_INFO";
public static final String SESSION_KEY_LOGIN_VO = "SMS_AUTH_LOGIN_VO";
// 시간 설정 (밀리초)
public static final long EXPIRY_MS = 60_000; // 1분
public static final long RESEND_INTERVAL_MS = 20_000; // 20초
// 인증번호 생성 모드
public static final String MODE_REAL = "real";
public static final String MODE_HHMM00 = "hhmm00";
public static final String MODE_FIXED = "fixed";
private final KjbProperty kjbProperty;
private final KjbUmsService kjbUmsService;
private final SecureRandom random = new SecureRandom();
public SmsAuthService(KjbProperty kjbProperty) {
this.kjbProperty = kjbProperty;
this.kjbUmsService = new KjbUmsService(kjbProperty);
}
/**
* SMS 인증이 필요한지 확인
*/
public boolean isRequired(UserInfo userInfo) {
if (!kjbProperty.isSmsAuthEnabled()) {
log.debug("SMS 인증 비활성화 상태");
return false;
}
String phone = userInfo.getCphnno();
if (StringUtils.isEmpty(phone)) {
log.warn("SMS 인증 필요하나 휴대폰번호 없음 - userId: {}", userInfo.getUserid());
// 전화번호 없으면 인증 불가이므로 isRequired는 true 반환하고
// 컨트롤러에서 에러 처리
return true;
}
return true;
}
/**
* 사용자의 휴대폰번호가 유효한지 확인
*/
public boolean hasValidPhoneNumber(UserInfo userInfo) {
String phone = userInfo.getCphnno();
return StringUtils.isNotEmpty(phone) && phone.replaceAll("[^0-9]", "").length() >= 10;
}
/**
* 인증번호 생성
*/
public String generateAuthCode() {
String mode = kjbProperty.getSmsAuthMode();
if (MODE_FIXED.equalsIgnoreCase(mode)) {
String fixedValue = kjbProperty.getSmsAuthFixedValue();
log.debug("SMS 인증번호 생성 (fixed 모드): {}", fixedValue);
return fixedValue;
} else if (MODE_HHMM00.equalsIgnoreCase(mode)) {
String timeCode = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HHmm")) + "00";
log.debug("SMS 인증번호 생성 (hhmm00 모드): {}", timeCode);
return timeCode;
} else {
// real 모드 - 랜덤 6자리
String randomCode = String.format("%06d", random.nextInt(1000000));
log.debug("SMS 인증번호 생성 (real 모드): {}", randomCode);
return randomCode;
}
}
/**
* SMS 인증번호 발송
*/
public boolean sendAuthCode(String phone, String authCode) {
try {
String cleanedPhone = phone.replaceAll("[^0-9]", "").trim();
String guid = String.format("EAPIM_EMS-%s",
DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS").format(LocalDateTime.now()));
VerifyPhoneUmsTemplate content = VerifyPhoneUmsTemplate.builder()
.authNumber(authCode)
.build();
kjbUmsService.sendKakaoAlimTalk(guid, UmsBizWorkCode.VERIFY_PHONE, cleanedPhone, content);
log.info("SMS 인증번호 발송 완료 - guid: {}, phone: {}****{}",
guid, cleanedPhone.substring(0, 3), cleanedPhone.substring(cleanedPhone.length() - 4));
log.debug("SMS 인증번호 발송 - authCode: {}", authCode);
return true;
} catch (KjbUmsException e) {
log.error("SMS 인증번호 발송 실패", e);
return false;
}
}
/**
* 세션에 SMS 인증 정보 저장
*/
public void setupAuthSession(HttpSession session, UserInfo userInfo, LoginVo loginVo, String authCode) {
long now = System.currentTimeMillis();
session.setAttribute(SESSION_KEY_AUTH_CODE, authCode);
session.setAttribute(SESSION_KEY_ISSUED_AT, now);
session.setAttribute(SESSION_KEY_USER_INFO, userInfo);
session.setAttribute(SESSION_KEY_LOGIN_VO, loginVo);
log.debug("SMS 인증 세션 설정 완료 - userId: {}", userInfo.getUserid());
}
/**
* 인증번호 검증
*/
public boolean validateAuthCode(HttpSession session, String inputCode) {
String storedCode = (String) session.getAttribute(SESSION_KEY_AUTH_CODE);
Long issuedAt = (Long) session.getAttribute(SESSION_KEY_ISSUED_AT);
if (storedCode == null || issuedAt == null) {
log.warn("SMS 인증 세션 정보 없음");
return false;
}
if (isExpired(session)) {
log.warn("SMS 인증번호 만료됨");
return false;
}
boolean valid = storedCode.equals(inputCode);
if (valid) {
log.info("SMS 인증번호 검증 성공");
} else {
log.warn("SMS 인증번호 불일치");
}
return valid;
}
/**
* 인증번호 만료 여부 확인
*/
public boolean isExpired(HttpSession session) {
Long issuedAt = (Long) session.getAttribute(SESSION_KEY_ISSUED_AT);
if (issuedAt == null) {
return true;
}
return System.currentTimeMillis() - issuedAt > EXPIRY_MS;
}
/**
* 재발송 가능 여부 확인
*/
public boolean canResend(HttpSession session) {
Long issuedAt = (Long) session.getAttribute(SESSION_KEY_ISSUED_AT);
if (issuedAt == null) {
return true;
}
return System.currentTimeMillis() - issuedAt >= RESEND_INTERVAL_MS;
}
/**
* 남은 유효 시간 (밀리초)
*/
public long getRemainingTime(HttpSession session) {
Long issuedAt = (Long) session.getAttribute(SESSION_KEY_ISSUED_AT);
if (issuedAt == null) {
return 0;
}
long remaining = EXPIRY_MS - (System.currentTimeMillis() - issuedAt);
return Math.max(0, remaining);
}
/**
* 재발송 가능 시간까지 남은 시간 (밀리초)
*/
public long getResendWaitTime(HttpSession session) {
Long issuedAt = (Long) session.getAttribute(SESSION_KEY_ISSUED_AT);
if (issuedAt == null) {
return 0;
}
long wait = RESEND_INTERVAL_MS - (System.currentTimeMillis() - issuedAt);
return Math.max(0, wait);
}
/**
* 세션에서 UserInfo 조회
*/
public UserInfo getUserInfoFromSession(HttpSession session) {
return (UserInfo) session.getAttribute(SESSION_KEY_USER_INFO);
}
/**
* 세션에서 LoginVo 조회
*/
public LoginVo getLoginVoFromSession(HttpSession session) {
return (LoginVo) session.getAttribute(SESSION_KEY_LOGIN_VO);
}
/**
* SMS 인증 세션 정보 클리어
*/
public void clearAuthSession(HttpSession session) {
session.removeAttribute(SESSION_KEY_AUTH_CODE);
session.removeAttribute(SESSION_KEY_ISSUED_AT);
session.removeAttribute(SESSION_KEY_USER_INFO);
session.removeAttribute(SESSION_KEY_LOGIN_VO);
log.debug("SMS 인증 세션 정보 클리어 완료");
}
/**
* 만료 시각 (timestamp) 반환
*/
public long getExpiresAt(HttpSession session) {
Long issuedAt = (Long) session.getAttribute(SESSION_KEY_ISSUED_AT);
if (issuedAt == null) {
return System.currentTimeMillis();
}
return issuedAt + EXPIRY_MS;
}
/**
* 재발송 가능 시각 (timestamp) 반환
*/
public long getResendableAt(HttpSession session) {
Long issuedAt = (Long) session.getAttribute(SESSION_KEY_ISSUED_AT);
if (issuedAt == null) {
return System.currentTimeMillis();
}
return issuedAt + RESEND_INTERVAL_MS;
}
/**
* 전화번호 마스킹
*/
public String maskPhoneNumber(String phone) {
if (StringUtils.isEmpty(phone)) {
return "****";
}
String cleaned = phone.replaceAll("[^0-9]", "");
if (cleaned.length() < 7) {
return "****";
}
return cleaned.substring(0, 3) + "****" + cleaned.substring(cleaned.length() - 4);
}
/**
* SMS 인증 활성화 여부
*/
public boolean isEnabled() {
return kjbProperty.isSmsAuthEnabled();
}
}