@@ -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