@@ -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")
|
||||
.build();
|
||||
return ChangePasswordResponseDto.builder()
|
||||
.success(true)
|
||||
.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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user