비밀번호 변경시 SMS 인증 추가
eapim-admin CI / build (push) Has been cancelled

This commit is contained in:
eastargh
2026-08-27 16:05:22 +09:00
parent 95ce7f9150
commit 6e4abf9f27
7 changed files with 224 additions and 15 deletions
+32 -3
View File
@@ -65,6 +65,7 @@
// SMS 인증 관련 변수
var smsAuthTimer = null;
var resendTimer = null;
var smsAuthPurpose = 'LOGIN'; // 'LOGIN' | 'PASSWORD_CHANGE' - SMS 인증 모달을 어느 흐름에서 띄웠는지 구분
function fncPreMain() {
if ($("input[name=userId]").val() ==null || $("input[name=userId]").val().length == 0 ){
@@ -96,6 +97,7 @@
}
else if (response.smsAuthRequired) {
// SMS 인증 필요
smsAuthPurpose = 'LOGIN';
showSmsAuthModal(response);
} else if (response.success) {
// 직접 로그인 성공
@@ -123,11 +125,21 @@
function showSmsAuthModal(data) {
$('#smsAuthMaskedPhone').text(data.maskedPhone);
$('#smsAuthCode').val('');
updateSmsTestHint(data.smsTestHint);
startExpiryTimer(data.expiresAt);
startResendTimer(data.resendableAt);
$('#smsAuthModal').modal('show');
}
// fixed 모드(djb.sms_auth.mode=fixed)일 때만 서버가 내려주는 테스트용 인증번호 힌트 표시
function updateSmsTestHint(hint) {
if (hint) {
$('#smsAuthTestHint').text(hint).show();
} else {
$('#smsAuthTestHint').text('').hide();
}
}
// 유효시간 타이머
function startExpiryTimer(expiresAt) {
clearInterval(smsAuthTimer);
@@ -181,6 +193,7 @@
dataType: 'json',
success: function(data) {
if (data.sent) {
updateSmsTestHint(data.smsTestHint);
startExpiryTimer(data.expiresAt);
startResendTimer(data.resendableAt);
alert('인증번호가 재발송되었습니다.');
@@ -194,7 +207,7 @@
});
}
// SMS 인증번호 확인
// SMS 인증번호 확인 (로그인 / 비밀번호 변경 공용 - smsAuthPurpose로 분기)
function verifySmsAuthCode() {
var code = $('#smsAuthCode').val();
if (!code || code.length !== 6) {
@@ -203,17 +216,25 @@
return;
}
var isPasswordChange = (smsAuthPurpose === 'PASSWORD_CHANGE');
var verifyUrl = isPasswordChange
? '<c:url value="/sms-auth/verify-password-change.json"/>'
: '<c:url value="/sms-auth/verify.json"/>';
$.ajax({
url: '<c:url value="/sms-auth/verify.json"/>',
url: verifyUrl,
type: 'POST',
data: { authCode: code },
dataType: 'json',
success: function(data) {
if (data.success) {
$('#smsAuthModal').modal('hide');
if (isPasswordChange) {
alert(data.message);
}
window.location.href = data.redirectUrl;
} else {
alert(data.errorMessage || '인증에 실패했습니다.');
alert(data.errorMessage || data.message || '인증에 실패했습니다.');
}
},
error: function() {
@@ -275,6 +296,13 @@
data: $('#modalLoginForm').serialize(),
dataType: 'json',
success: function(response) {
if (response.smsAuthRequired) {
// 비밀번호는 아직 반영되지 않음 - SMS 인증 완료 시 최종 반영됨
smsAuthPurpose = 'PASSWORD_CHANGE';
$('#pwdChgModal').modal('hide');
showSmsAuthModal(response);
return;
}
alert(response.message);
if (response.success) {
window.location.href = response.redirectUrl || '<c:url value="/emergency.jsp"/>';
@@ -462,6 +490,7 @@
placeholder="인증번호 6자리" maxlength="6"
onkeypress="return event.charCode >= 48 && event.charCode <= 57">
</div>
<p id="smsAuthTestHint" class="text-warning" style="display:none;"></p>
<p class="text-muted">남은 시간: <span id="smsAuthRemainingTime" class="text-danger font-weight-bold">60</span>초</p>
</div>
<div class="modal-footer">
@@ -25,4 +25,29 @@ public class ChangePasswordResponseDto {
* 성공 시 이동할 URL
*/
private String redirectUrl;
/**
* SMS 2차 인증 필요 여부 (true면 아직 비밀번호가 반영되지 않고 인증 대기 중)
*/
private boolean smsAuthRequired;
/**
* 마스킹된 전화번호 (예: 010****1234)
*/
private String maskedPhone;
/**
* 인증번호 만료 시각 (timestamp, 밀리초)
*/
private long expiresAt;
/**
* 재발급 가능 시각 (timestamp, 밀리초)
*/
private long resendableAt;
/**
* djb.sms_auth.mode=fixed 일 때만 채워지는 테스트용 인증번호 안내 문구 (예: "[테스트] 인증번호: 000000")
*/
private String smsTestHint;
}
@@ -50,4 +50,9 @@ public class LoginResponseDto {
* 초기 비밀번호 변경 필요 여부
*/
private boolean changePassword;
/**
* djb.sms_auth.mode=fixed 일 때만 채워지는 테스트용 인증번호 안내 문구 (예: "[테스트] 인증번호: 000000")
*/
private String smsTestHint;
}
@@ -250,6 +250,7 @@ public class MainController implements InterceptorSkipController {
.maskedPhone(smsAuthService.maskPhoneNumber(userInfo.getCphnno()))
.expiresAt(smsAuthService.getExpiresAt(session))
.resendableAt(smsAuthService.getResendableAt(session))
.smsTestHint(smsAuthService.getFixedModeHintText())
.build();
}
@@ -907,21 +908,41 @@ public class MainController implements InterceptorSkipController {
.build();
}
// 5. 비밀번호 변경
userInfo.setPassword(newPasswordHash);
userInfoService.save(userInfo);
// 5. SMS 2차 인증 필요 여부 체크 (비상모드는 로그인과 동일하게 우회)
Boolean emergencyMode = (Boolean) session.getAttribute("emergencyMode");
if (!Boolean.TRUE.equals(emergencyMode) && smsAuthService.isEnabled()) {
if (!smsAuthService.hasValidPhoneNumber(userInfo)) {
UserAccessLogger.log(request, LOG_CATEGORY_LOGIN, "F", "휴대폰번호 미등록으로 비밀번호 변경 불가");
return ChangePasswordResponseDto.builder()
.success(false)
.message(toAlertMessage("휴대폰번호가 등록되어 있지 않아 비밀번호를 변경할 수 없습니다. 관리자에게 문의하세요."))
.build();
}
// 6. 비밀번호 변경 이력 저장 (재사용 금지 검증용)
userPasswordHistoryService.recordPasswordChange(resetUserId, newPasswordHash);
String authCode = smsAuthService.generateAuthCode();
boolean sent = smsAuthService.sendAuthCode(userInfo, authCode);
if (!sent) {
return ChangePasswordResponseDto.builder()
.success(false)
.message(toAlertMessage("인증번호 발송에 실패했습니다. 잠시 후 다시 시도해주세요."))
.build();
}
UserAccessLogger.log(request, LOG_CATEGORY_LOGIN, "S", localeMessage
.getString("login.pwdchanged"));
// 실제 저장은 SMS 인증 성공 후(commitPendingPasswordChange)에 수행한다.
smsAuthService.setupPasswordChangeAuthSession(session, userInfo, newPasswordHash, authCode);
return ChangePasswordResponseDto.builder()
.success(true)
.message(toAlertMessage(localeMessage.getString("login.pwdchanged")))
.redirectUrl(request.getContextPath() + "/emergency.jsp")
.smsAuthRequired(true)
.maskedPhone(smsAuthService.maskPhoneNumber(userInfo.getCphnno()))
.expiresAt(smsAuthService.getExpiresAt(session))
.resendableAt(smsAuthService.getResendableAt(session))
.smsTestHint(smsAuthService.getFixedModeHintText())
.build();
}
// SMS 인증 비활성화 또는 비상모드 - 기존처럼 즉시 반영
return applyPasswordChange(request, userInfo, newPasswordHash);
} catch (Exception e) {
logger.error("changePassword Error ", e);
@@ -933,6 +954,52 @@ public class MainController implements InterceptorSkipController {
}
/**
* 비밀번호 변경 SMS 인증 성공 후 호출. 세션에 대기 중인 새 비밀번호를 실제로 반영한다.
* ({@code SmsAuthController}에서 인증번호 검증 성공 시 호출)
*/
public ChangePasswordResponseDto commitPendingPasswordChange(HttpServletRequest request, HttpSession session) {
UserInfo userInfo = smsAuthService.getUserInfoFromSession(session);
String pendingPasswordHash = smsAuthService.getPendingPasswordHashFromSession(session);
if (userInfo == null || pendingPasswordHash == null) {
return ChangePasswordResponseDto.builder()
.success(false)
.message(toAlertMessage("인증 세션이 만료되었습니다. 다시 시도해주세요."))
.build();
}
try {
ChangePasswordResponseDto result = applyPasswordChange(request, userInfo, pendingPasswordHash);
smsAuthService.clearAuthSession(session);
return result;
} catch (Exception e) {
logger.error("commitPendingPasswordChange Error ", e);
smsAuthService.clearAuthSession(session);
return ChangePasswordResponseDto.builder()
.success(false)
.message(toAlertMessage(localeMessage.getString("login.pwdchangefail")))
.build();
}
}
// 비밀번호 저장 + 변경 이력 기록 (SMS 인증 완료 여부와 무관하게 최종 반영 단계에서 공통 호출)
private ChangePasswordResponseDto applyPasswordChange(HttpServletRequest request, UserInfo userInfo, String newPasswordHash) {
userInfo.setPassword(newPasswordHash);
userInfoService.save(userInfo);
userPasswordHistoryService.recordPasswordChange(userInfo.getUserid(), newPasswordHash);
UserAccessLogger.log(request, LOG_CATEGORY_LOGIN, "S", localeMessage
.getString("login.pwdchanged"));
return ChangePasswordResponseDto.builder()
.success(true)
.message(toAlertMessage(localeMessage.getString("login.pwdchanged")))
.redirectUrl(request.getContextPath() + "/emergency.jsp")
.build();
}
private static final int PASSWORD_PATTERN_CHECK_LENGTH = 3;
private static final String[] KEYBOARD_SEQUENTIAL_ROWS = {
@@ -2,6 +2,7 @@ package com.eactive.eai.rms.ext.djb.smsauth;
import com.eactive.apim.portal.user.entity.UserInfo;
import com.eactive.eai.rms.common.interceptor.InterceptorSkipController;
import com.eactive.eai.rms.common.login.ChangePasswordResponseDto;
import com.eactive.eai.rms.common.login.LoginVo;
import com.eactive.eai.rms.common.login.MainController;
import lombok.extern.slf4j.Slf4j;
@@ -70,6 +71,7 @@ public class SmsAuthController implements InterceptorSkipController {
.maskedPhone(getSmsAuthService().maskPhoneNumber(userInfo.getCphnno()))
.expiresAt(getSmsAuthService().getExpiresAt(session))
.resendableAt(getSmsAuthService().getResendableAt(session))
.smsTestHint(getSmsAuthService().getFixedModeHintText())
.build();
} else {
return SmsAuthDto.builder()
@@ -119,6 +121,7 @@ public class SmsAuthController implements InterceptorSkipController {
.maskedPhone(getSmsAuthService().maskPhoneNumber(userInfo.getCphnno()))
.expiresAt(getSmsAuthService().getExpiresAt(session))
.resendableAt(getSmsAuthService().getResendableAt(session))
.smsTestHint(getSmsAuthService().getFixedModeHintText())
.build();
} else {
return SmsAuthDto.builder()
@@ -208,6 +211,45 @@ public class SmsAuthController implements InterceptorSkipController {
}
}
/**
* SMS 인증번호 검증 및 비밀번호 변경 완료
*/
@RequestMapping(value = "/verify-password-change.json", method = RequestMethod.POST)
@ResponseBody
public ChangePasswordResponseDto verifyPasswordChangeAuthCode(HttpServletRequest request,
@RequestParam("authCode") String authCode) {
HttpSession session = request.getSession();
UserInfo userInfo = getSmsAuthService().getUserInfoFromSession(session);
String pendingPasswordHash = getSmsAuthService().getPendingPasswordHashFromSession(session);
if (userInfo == null || pendingPasswordHash == null) {
log.warn("SMS 인증 세션 정보 없음 - verify-password-change 요청");
return ChangePasswordResponseDto.builder()
.success(false)
.message("인증 세션이 만료되었습니다. 다시 시도해주세요.")
.build();
}
if (getSmsAuthService().isExpired(session)) {
log.warn("SMS 인증번호 만료됨(비밀번호 변경) - userId: {}", userInfo.getUserid());
getSmsAuthService().clearAuthSession(session);
return ChangePasswordResponseDto.builder()
.success(false)
.message("인증번호가 만료되었습니다. 다시 시도해주세요.")
.build();
}
if (!getSmsAuthService().validateAuthCode(session, authCode)) {
log.warn("SMS 인증번호 불일치(비밀번호 변경) - userId: {}", userInfo.getUserid());
return ChangePasswordResponseDto.builder()
.success(false)
.message("인증번호가 일치하지 않습니다.")
.build();
}
return mainController.commitPendingPasswordChange(request, session);
}
/**
* SMS 인증 상태 조회
*/
@@ -50,4 +50,9 @@ public class SmsAuthDto {
* 에러 메시지
*/
private String errorMessage;
/**
* djb.sms_auth.mode=fixed 일 때만 채워지는 테스트용 인증번호 안내 문구 (예: "[테스트] 인증번호: 000000")
*/
private String smsTestHint;
}
@@ -30,6 +30,7 @@ public class SmsAuthService {
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 String SESSION_KEY_PENDING_PASSWORD_HASH = "SMS_AUTH_PENDING_PASSWORD_HASH";
// 시간 설정 (밀리초)
public static final long EXPIRY_MS = 60_000; // 1분
@@ -130,6 +131,27 @@ public class SmsAuthService {
log.debug("SMS 인증 세션 설정 완료 - userId: {}", userInfo.getUserid());
}
/**
* 비밀번호 변경용 SMS 인증 세션 설정.
* 로그인 플로우의 {@link #setupAuthSession}과 세션 키(AUTH_CODE/ISSUED_AT/USER_INFO)를 공유하되,
* LOGIN_VO 대신 검증 성공 시 실제로 반영할 새 비밀번호 해시를 저장한다.
*/
public void setupPasswordChangeAuthSession(HttpSession session, UserInfo userInfo, String pendingPasswordHash, 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_PENDING_PASSWORD_HASH, pendingPasswordHash);
log.debug("SMS 인증 세션(비밀번호 변경용) 설정 완료 - userId: {}", userInfo.getUserid());
}
/**
* 세션에서 대기 중인 새 비밀번호 해시 조회 (비밀번호 변경 SMS 인증용)
*/
public String getPendingPasswordHashFromSession(HttpSession session) {
return (String) session.getAttribute(SESSION_KEY_PENDING_PASSWORD_HASH);
}
/**
* 인증번호 검증
*/
@@ -224,6 +246,7 @@ public class SmsAuthService {
session.removeAttribute(SESSION_KEY_ISSUED_AT);
session.removeAttribute(SESSION_KEY_USER_INFO);
session.removeAttribute(SESSION_KEY_LOGIN_VO);
session.removeAttribute(SESSION_KEY_PENDING_PASSWORD_HASH);
log.debug("SMS 인증 세션 정보 클리어 완료");
}
@@ -266,4 +289,17 @@ public class SmsAuthService {
public boolean isEnabled() {
return getProperty().isSmsAuthEnabled();
}
/**
* fixed 모드(djb.sms_auth.mode=fixed)일 때 화면에 표시할 테스트용 안내 문구.
* 실제 SMS 발송 없이도 개발/테스트 환경에서 인증번호를 알 수 있도록 노출한다.
* fixed 모드가 아니면 null (화면에서는 힌트를 표시하지 않음).
*/
public String getFixedModeHintText() {
DjbProperty prop = getProperty();
if (!MODE_FIXED.equalsIgnoreCase(prop.getSmsAuthMode())) {
return null;
}
return "[테스트] 인증번호: " + prop.getSmsAuthFixedValue();
}
}