- 인증번호 재발송 제한 메시지 개선 - 남은 초 표시 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- 비밀번호 확인 필드명 일괄 변경(password2 → confirmPassword)
- 법인 관리자 탈퇴 제한 경고 메시지 추가 및 관련 로직 수정
- 임시 비밀번호 발급 시 기존 비밀번호 이력 저장 로직 추가
This commit is contained in:
Rinjae
2026-08-24 13:17:14 +09:00
parent b41a81bda3
commit 78b1edff5a
13 changed files with 198 additions and 26 deletions
@@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
@Service
@@ -104,9 +105,15 @@ public class AuthNumberServiceImpl implements AuthNumberService {
private void validateResendTime(String recipientKey) {
storage.getAuthNumber(recipientKey).ifPresent(existingAuth -> {
LocalDateTime now = LocalDateTime.now();
if (existingAuth.getExpiresAt().minusSeconds(authNumberExpirationTime)
.plusSeconds(resendLimitSeconds).isAfter(now)) {
throw new AuthNumberException("잠시 후에 다시 시도해 주세요.");
LocalDateTime resendAvailableAt = existingAuth.getExpiresAt()
.minusSeconds(authNumberExpirationTime)
.plusSeconds(resendLimitSeconds);
if (resendAvailableAt.isAfter(now)) {
long remainingMillis = Duration.between(now, resendAvailableAt).toMillis();
long remainingSeconds = Math.max(1L, (remainingMillis + 999L) / 1000L);
throw new AuthNumberException(
String.format("인증번호 재발송 제한이 적용 중입니다. %d초 후 다시 시도해 주세요.",
remainingSeconds));
}
});
}
@@ -35,8 +35,8 @@ public class UserRegisterRestController {
}
@PostMapping("/check_password_match")
public ResponseEntity<ValidationResponse> checkPasswordMatch(@RequestParam String password, @RequestParam String password2) {
return ResponseEntity.ok(userRegisterFacade.checkPasswordMatch(password, password2));
public ResponseEntity<ValidationResponse> checkPasswordMatch(@RequestParam String password, @RequestParam String confirmPassword) {
return ResponseEntity.ok(userRegisterFacade.checkPasswordMatch(password, confirmPassword));
}
@PostMapping("/register/confirm_password")
@@ -10,7 +10,7 @@ import org.hibernate.validator.constraints.NotEmpty;
@AuthNumberMatch(recipient = "loginId", authField = "authNumber")
@PasswordMatch(input = "password", confirm = "password2")
@PasswordMatch(input = "password", confirm = "confirmPassword")
@Data
@PasswordRule(password = "password", loginId = "loginId", mobile = "mobileNumber")
public class PortalUserRegistrationDTO {
@@ -31,8 +31,6 @@ public class PortalUserRegistrationDTO {
*/
private String password;
private String password2;
@CellPhone
private String mobileNumber;
@@ -140,11 +140,11 @@ public class UserFacadeImpl implements UserFacade {
public void withdrawUser(String userId, String withdrawalReason) {
PortalUser user = portalUserService.findById(userId);
// 법인 관리자 탈퇴 제한
// 법인 관리자는 권한 이관 전 탈퇴할 수 없다.
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER) {
if(portalUserService.checkOrgHasOtherUsers(user.getPortalOrg())){
throw new IllegalArgumentException("법인 관리자권한을 다른 개발자에게 위임하신 후 탈퇴가 가능합니다.");
}
throw new IllegalArgumentException(
"법인 관리자는 회원 탈퇴를 할 수 없습니다. "
+ "관리자 권한을 다른 사용자에게 이관하거나 담당자에게 연락해 주세요.");
}
// 약관 동의 정보 삭제
@@ -19,7 +19,7 @@ public interface UserRegisterFacade {
ValidationResponse checkPassword(String password, String loginId, String mobileNumber);
ValidationResponse checkPasswordMatch(String password, String password2);
ValidationResponse checkPasswordMatch(String password, String confirmPassword);
ValidationResponse verifyPassword(String loginId, String confirmPassword);
@@ -94,8 +94,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
}
@Override
public ValidationResponse checkPasswordMatch(String password, String password2) {
boolean isMatch = password.equals(password2);
public ValidationResponse checkPasswordMatch(String password, String confirmPassword) {
boolean isMatch = password.equals(confirmPassword);
String message = isMatch ? "비밀번호가 일치합니다." : "비밀번호가 일치하지 않습니다.";
return new ValidationResponse(isMatch, message);
}
@@ -85,6 +85,17 @@ public class PasswordService {
return result;
}
/**
* {@link #updatePassword} 를 거치지 않고 비밀번호 해시를 직접 바꾸는 지점(예: 비밀번호 초기화로
* 임시 비밀번호 발급 — PortalUserAuthService.resetPassword)이 <b>덮어쓰기 직전</b>에 호출해,
* 지금 버려지는 비밀번호를 이력에 남긴다. 이걸 빼먹으면 재사용 금지(최근 5회) 검증이 그 비밀번호를
* 전혀 모른 채로 남아 있어, 초기화 이후 바로 예전 비밀번호로 되돌리는 게 허용되는 보안 허점이 된다.
*/
@Transactional
public void recordExternalPasswordChange(String userId, String previousPasswordHash) {
savePasswordHistory(userId, previousPasswordHash);
}
private void checkPasswordHistory(String userId, String newPassword) {
List<UserPasswordHistory> passwordHistories = passwordHistoryRepository.findRecentPasswordsByUserId(userId);
@@ -56,6 +56,7 @@ public class PortalUserAuthService implements UserDetailsService {
private final MessageRequestRepository messageRequestRepository;
private final EncryptionUtil encryptionUtil;
private final LoginFinalizer loginFinalizer;
private final PasswordService passwordService;
@Override
@Transactional(noRollbackFor = UsernameNotFoundException.class)
@@ -164,6 +165,10 @@ public class PortalUserAuthService implements UserDetailsService {
.orElseThrow(() -> new UserNotFoundException("일치하는 사용자 정보를 찾을 수 없습니다."));
String tempPassword = EncryptionUtil.generateNewPassword();
// 지금 버려지는(임시 비밀번호로 교체되는) 비밀번호를 이력에 남긴다 — 안 남기면 재사용 금지
// (최근 5회) 검증이 이 비밀번호를 모른 채로 남아, 초기화 직후 바로 예전 비밀번호로 되돌리는
// 것이 허용되는 보안 허점이 생긴다.
passwordService.recordExternalPasswordChange(portalUser.getId(), portalUser.getPasswordHash());
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
// 임시 비밀번호 발급 → 변경일을 null 로 초기화해 로그인 시 강제 비밀번호 변경을 유도한다
// (LoginFinalizer.applyPostLoginState 의 passwordChangeDate == null 분기)
@@ -754,15 +754,15 @@
if (withdrawalBtn) {
withdrawalBtn.addEventListener('click', (e) => {
e.preventDefault();
customPopups.showWithdrawal();
customPopups.showAlert(
'법인 관리자는 회원 탈퇴를 할 수 없습니다.<br>' +
'관리자 권한을 다른 사용자에게 이관하거나 담당자에게 연락해 주세요.'
);
});
}
});
</script>
</th:block>
<section layout:fragment="pagePopups">
<th:block th:replace="~{fragment/popup/withdrawalPopup :: withdrawalPopup}"></th:block>
</section>
</body>
</html>
</html>
@@ -110,7 +110,7 @@
비밀번호 확인 <span class="required-badge">필수</span>
</label>
<div class="org-form-input-wrapper">
<input type="password" name="password2" id="password2" class="org-form-input"
<input type="password" name="confirmPassword" id="confirmPassword" class="org-form-input"
th:placeholder="#{portalUser.Register.passConfirm}">
<input type="hidden" name="isPasswordMatch" id="isPasswordMatch" />
<div id="password-match-validation" class="org-validation-message"></div>
@@ -411,9 +411,9 @@
});
// 비밀번호 확인 검증
$('#password2').on('blur', function () {
let password2 = $(this).val();
if (!password2) {
$('#confirmPassword').on('blur', function () {
let confirmPassword = $(this).val();
if (!confirmPassword) {
return;
}
@@ -422,7 +422,7 @@
type: 'POST',
data: {
password: $('#password').val(),
password2: password2,
confirmPassword: confirmPassword,
_csrf: $('input[name="_csrf"]').val()
},
success: function (response) {
@@ -136,7 +136,7 @@
// 시나리오별 필수 필드 정의
const requiredFieldsByScenario = {
new: {
user: ['loginId', 'userName', 'password', 'password2', 'mobileNumber', 'authNumber'],
user: ['loginId', 'userName', 'password', 'confirmPassword', 'mobileNumber', 'authNumber'],
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
},
retain: {
@@ -228,6 +228,9 @@
});
// Add confirmPassword manually based on the scenario
// (new 시나리오는 #confirmPassword 필드 자체가 있어 위 공통 user 필드 루프가 그대로 처리한다.
// 서버는 시나리오 무관하게 confirmPassword 단일 필드로 비밀번호 확인을 검증한다 —
// OrgRegisterFacadeImpl.registerNewOrgUser 참고.)
if (registrationScenario === 'retain') {
const passwordConfirmIndividual = document.getElementById('passwordConfirmIndividual');
if (passwordConfirmIndividual) {
@@ -0,0 +1,65 @@
package com.eactive.apim.portal.apps.auth.service;
import com.eactive.apim.portal.portaluser.entity.TwoFactorAuth;
import com.eactive.apim.portal.portaluser.service.AuthNumberException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AuthNumberServiceImplTest {
private static final Pattern RETRY_SECONDS = Pattern.compile(
"인증번호 재발송 제한이 적용 중입니다\\. (\\d+)초 후 다시 시도해 주세요\\.");
@Mock
private AuthNumberStorage storage;
@Mock
private AuthNumberGenerator generator;
@Mock
private MessageSender messageSender;
private AuthNumberServiceImpl service;
@BeforeEach
void setUp() {
service = new AuthNumberServiceImpl(storage, generator, messageSender);
ReflectionTestUtils.setField(service, "authNumberExpirationTime", 300);
ReflectionTestUtils.setField(service, "resendLimitSeconds", 30);
}
@Test
void resendLimitMessageIncludesRemainingSeconds() {
String recipient = "01099121100";
TwoFactorAuth existing = new TwoFactorAuth(
recipient, "123456", LocalDateTime.now().plusSeconds(300));
when(storage.getAuthNumber(recipient)).thenReturn(Optional.of(existing));
AuthNumberException exception = assertThrows(
AuthNumberException.class,
() -> service.sendRequestAuthNumber(recipient, "SMS")
);
Matcher matcher = RETRY_SECONDS.matcher(exception.getMessage());
assertTrue(matcher.matches(), "남은 재시도 초가 안내 메시지에 포함되어야 함");
long remainingSeconds = Long.parseLong(matcher.group(1));
assertTrue(remainingSeconds >= 1 && remainingSeconds <= 30,
"남은 초는 1~30 범위여야 함: " + remainingSeconds);
assertNull(exception.getReason());
verifyNoInteractions(generator, messageSender);
}
}
@@ -0,0 +1,83 @@
package com.eactive.apim.portal.apps.user;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.user.facade.MessageRequestFacade;
import com.eactive.apim.portal.apps.user.facade.UserFacadeImpl;
import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
import com.eactive.apim.portal.apps.user.service.PasswordService;
import com.eactive.apim.portal.apps.user.service.PortalOrgService;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class UserFacadeImplTest {
@Mock
private PortalUserService portalUserService;
@Mock
private PortalOrgService portalOrgService;
@Mock
private PasswordService passwordService;
@Mock
private PortalUserMapper portalUserMapper;
@Mock
private MessageHandlerService messageHandlerService;
@Mock
private AgreementsFacade agreementsFacade;
@Mock
private MessageRequestFacade messageRequestFacade;
@InjectMocks
private UserFacadeImpl userFacade;
@Test
void corporateManagerCannotWithdraw() {
PortalUser manager = new PortalUser();
manager.setId("manager-1");
manager.setRoleCode(RoleCode.ROLE_CORP_MANAGER);
when(portalUserService.findById("manager-1")).thenReturn(manager);
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> userFacade.withdrawUser("manager-1", "withdrawal reason")
);
assertEquals(
"법인 관리자는 회원 탈퇴를 할 수 없습니다. "
+ "관리자 권한을 다른 사용자에게 이관하거나 담당자에게 연락해 주세요.",
exception.getMessage()
);
verifyNoInteractions(agreementsFacade, messageRequestFacade);
verify(portalUserService, never()).deleteUser(manager, "withdrawal reason");
}
@Test
void corporateUserCanWithdraw() {
PortalUser user = new PortalUser();
user.setId("user-1");
user.setLoginId("corp-user@example.com");
user.setUserName("법인 사용자");
user.setRoleCode(RoleCode.ROLE_CORP_USER);
when(portalUserService.findById("user-1")).thenReturn(user);
userFacade.withdrawUser("user-1", "withdrawal reason");
verify(agreementsFacade).deleteUserAgreements("user-1");
verify(messageRequestFacade).deleteUserMessage("법인 사용자", "corp-user@example.com");
verify(portalUserService).deleteUser(user, "withdrawal reason");
}
}