- 인증번호 재발송 제한 메시지 개선 - 남은 초 표시 추가
- 비밀번호 확인 필드명 일괄 변경(password2 → confirmPassword) - 법인 관리자 탈퇴 제한 경고 메시지 추가 및 관련 로직 수정 - 임시 비밀번호 발급 시 기존 비밀번호 이력 저장 로직 추가
This commit is contained in:
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+65
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user