diff --git a/src/main/java/com/eactive/apim/portal/apps/auth/service/AuthNumberServiceImpl.java b/src/main/java/com/eactive/apim/portal/apps/auth/service/AuthNumberServiceImpl.java index b140f76..9c23355 100644 --- a/src/main/java/com/eactive/apim/portal/apps/auth/service/AuthNumberServiceImpl.java +++ b/src/main/java/com/eactive/apim/portal/apps/auth/service/AuthNumberServiceImpl.java @@ -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)); } }); } diff --git a/src/main/java/com/eactive/apim/portal/apps/user/controller/UserRegisterRestController.java b/src/main/java/com/eactive/apim/portal/apps/user/controller/UserRegisterRestController.java index c6c86bb..3d46493 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/controller/UserRegisterRestController.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/controller/UserRegisterRestController.java @@ -35,8 +35,8 @@ public class UserRegisterRestController { } @PostMapping("/check_password_match") - public ResponseEntity checkPasswordMatch(@RequestParam String password, @RequestParam String password2) { - return ResponseEntity.ok(userRegisterFacade.checkPasswordMatch(password, password2)); + public ResponseEntity checkPasswordMatch(@RequestParam String password, @RequestParam String confirmPassword) { + return ResponseEntity.ok(userRegisterFacade.checkPasswordMatch(password, confirmPassword)); } @PostMapping("/register/confirm_password") diff --git a/src/main/java/com/eactive/apim/portal/apps/user/dto/PortalUserRegistrationDTO.java b/src/main/java/com/eactive/apim/portal/apps/user/dto/PortalUserRegistrationDTO.java index de74130..519d6b3 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/dto/PortalUserRegistrationDTO.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/dto/PortalUserRegistrationDTO.java @@ -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; diff --git a/src/main/java/com/eactive/apim/portal/apps/user/facade/UserFacadeImpl.java b/src/main/java/com/eactive/apim/portal/apps/user/facade/UserFacadeImpl.java index db69e52..6dd1ded 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/facade/UserFacadeImpl.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/facade/UserFacadeImpl.java @@ -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( + "법인 관리자는 회원 탈퇴를 할 수 없습니다. " + + "관리자 권한을 다른 사용자에게 이관하거나 담당자에게 연락해 주세요."); } // 약관 동의 정보 삭제 diff --git a/src/main/java/com/eactive/apim/portal/apps/user/facade/UserRegisterFacade.java b/src/main/java/com/eactive/apim/portal/apps/user/facade/UserRegisterFacade.java index 470df50..55571f4 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/facade/UserRegisterFacade.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/facade/UserRegisterFacade.java @@ -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); diff --git a/src/main/java/com/eactive/apim/portal/apps/user/facade/UserRegisterFacadeImpl.java b/src/main/java/com/eactive/apim/portal/apps/user/facade/UserRegisterFacadeImpl.java index 3ef2f33..7b79d46 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/facade/UserRegisterFacadeImpl.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/facade/UserRegisterFacadeImpl.java @@ -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); } diff --git a/src/main/java/com/eactive/apim/portal/apps/user/service/PasswordService.java b/src/main/java/com/eactive/apim/portal/apps/user/service/PasswordService.java index cd39377..7d17a74 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/service/PasswordService.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/service/PasswordService.java @@ -85,6 +85,17 @@ public class PasswordService { return result; } + /** + * {@link #updatePassword} 를 거치지 않고 비밀번호 해시를 직접 바꾸는 지점(예: 비밀번호 초기화로 + * 임시 비밀번호 발급 — PortalUserAuthService.resetPassword)이 덮어쓰기 직전에 호출해, + * 지금 버려지는 비밀번호를 이력에 남긴다. 이걸 빼먹으면 재사용 금지(최근 5회) 검증이 그 비밀번호를 + * 전혀 모른 채로 남아 있어, 초기화 이후 바로 예전 비밀번호로 되돌리는 게 허용되는 보안 허점이 된다. + */ + @Transactional + public void recordExternalPasswordChange(String userId, String previousPasswordHash) { + savePasswordHistory(userId, previousPasswordHash); + } + private void checkPasswordHistory(String userId, String newPassword) { List passwordHistories = passwordHistoryRepository.findRecentPasswordsByUserId(userId); diff --git a/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserAuthService.java b/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserAuthService.java index 7398c96..571a028 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserAuthService.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserAuthService.java @@ -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 분기) diff --git a/src/main/resources/templates/views/apps/mypage/updateCorporateManager.html b/src/main/resources/templates/views/apps/mypage/updateCorporateManager.html index a1b7448..7c4883f 100644 --- a/src/main/resources/templates/views/apps/mypage/updateCorporateManager.html +++ b/src/main/resources/templates/views/apps/mypage/updateCorporateManager.html @@ -754,15 +754,15 @@ if (withdrawalBtn) { withdrawalBtn.addEventListener('click', (e) => { e.preventDefault(); - customPopups.showWithdrawal(); + customPopups.showAlert( + '법인 관리자는 회원 탈퇴를 할 수 없습니다.
' + + '관리자 권한을 다른 사용자에게 이관하거나 담당자에게 연락해 주세요.' + ); }); } }); -
- -
- \ No newline at end of file + diff --git a/src/main/resources/templates/views/apps/register/components/newUserInfoForm.html b/src/main/resources/templates/views/apps/register/components/newUserInfoForm.html index 640618a..7b984e7 100644 --- a/src/main/resources/templates/views/apps/register/components/newUserInfoForm.html +++ b/src/main/resources/templates/views/apps/register/components/newUserInfoForm.html @@ -110,7 +110,7 @@ 비밀번호 확인 필수
-
@@ -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) { diff --git a/src/main/resources/templates/views/apps/register/orgUserRegister.html b/src/main/resources/templates/views/apps/register/orgUserRegister.html index 84711b2..0a7f8c9 100644 --- a/src/main/resources/templates/views/apps/register/orgUserRegister.html +++ b/src/main/resources/templates/views/apps/register/orgUserRegister.html @@ -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) { diff --git a/src/test/java/com/eactive/apim/portal/apps/auth/service/AuthNumberServiceImplTest.java b/src/test/java/com/eactive/apim/portal/apps/auth/service/AuthNumberServiceImplTest.java new file mode 100644 index 0000000..09f5187 --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/apps/auth/service/AuthNumberServiceImplTest.java @@ -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); + } +} diff --git a/src/test/java/com/eactive/apim/portal/apps/user/UserFacadeImplTest.java b/src/test/java/com/eactive/apim/portal/apps/user/UserFacadeImplTest.java new file mode 100644 index 0000000..7c3a24a --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/apps/user/UserFacadeImplTest.java @@ -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"); + } +}