diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 80b833a9c..defb0230f 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -56,6 +56,9 @@ public enum ErrorCode { ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "액세스 토큰이 만료되었습니다. 재발급 api를 호출해주세요."), REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "리프레시 토큰이 만료되었습니다. 다시 로그인을 진행해주세요."), ACCESS_DENIED(HttpStatus.FORBIDDEN.value(), "접근 권한이 없습니다."), + PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST.value(), "비밀번호가 일치하지 않습니다."), + PASSWORD_NOT_CHANGED(HttpStatus.BAD_REQUEST.value(), "현재 비밀번호와 새 비밀번호가 동일합니다."), + PASSWORD_NOT_CONFIRMED(HttpStatus.BAD_REQUEST.value(), "새 비밀번호가 일치하지 않습니다."), SIGN_IN_FAILED(HttpStatus.UNAUTHORIZED.value(), "로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요."), // s3 diff --git a/src/main/java/com/example/solidconnection/siteuser/controller/MyPageController.java b/src/main/java/com/example/solidconnection/siteuser/controller/MyPageController.java index 39dcda29a..534b6c361 100644 --- a/src/main/java/com/example/solidconnection/siteuser/controller/MyPageController.java +++ b/src/main/java/com/example/solidconnection/siteuser/controller/MyPageController.java @@ -3,11 +3,14 @@ import com.example.solidconnection.common.resolver.AuthorizedUser; import com.example.solidconnection.siteuser.dto.MyPageResponse; +import com.example.solidconnection.siteuser.dto.PasswordUpdateRequest; import com.example.solidconnection.siteuser.service.MyPageService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -37,4 +40,13 @@ public ResponseEntity updateMyPageInfo( myPageService.updateMyPageInfo(siteUserId, imageFile, nickname); return ResponseEntity.ok().build(); } + + @PatchMapping("/password") + public ResponseEntity updatePassword( + @AuthorizedUser long siteUserId, + @RequestBody @Valid PasswordUpdateRequest request + ) { + myPageService.updatePassword(siteUserId, request); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java index 0a15317c1..9f4f7ef0f 100644 --- a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java +++ b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java @@ -115,4 +115,8 @@ public SiteUser( this.authType = authType; this.password = password; } + + public void updatePassword(String newEncodedPassword) { + this.password = newEncodedPassword; + } } diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/PasswordUpdateRequest.java b/src/main/java/com/example/solidconnection/siteuser/dto/PasswordUpdateRequest.java new file mode 100644 index 000000000..3f3d7b077 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/dto/PasswordUpdateRequest.java @@ -0,0 +1,20 @@ +package com.example.solidconnection.siteuser.dto; + +import com.example.solidconnection.auth.dto.validation.Password; +import com.example.solidconnection.siteuser.dto.validation.PasswordConfirmation; +import jakarta.validation.constraints.NotBlank; + +@PasswordConfirmation +public record PasswordUpdateRequest( + @NotBlank(message = "현재 비밀번호를 입력해주세요.") + String currentPassword, + + @NotBlank(message = "새 비밀번호를 입력해주세요.") + @Password + String newPassword, + + @NotBlank(message = "새 비밀번호를 다시 한번 입력해주세요.") + String newPasswordConfirmation +) { + +} diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/validation/PasswordConfirmation.java b/src/main/java/com/example/solidconnection/siteuser/dto/validation/PasswordConfirmation.java new file mode 100644 index 000000000..cfc34638f --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/dto/validation/PasswordConfirmation.java @@ -0,0 +1,20 @@ +package com.example.solidconnection.siteuser.dto.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Constraint(validatedBy = PasswordConfirmationValidator.class) +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface PasswordConfirmation { + + String message() default "비밀번호 변경 과정에서 오류가 발생했습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/validation/PasswordConfirmationValidator.java b/src/main/java/com/example/solidconnection/siteuser/dto/validation/PasswordConfirmationValidator.java new file mode 100644 index 000000000..7524e4505 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/dto/validation/PasswordConfirmationValidator.java @@ -0,0 +1,45 @@ +package com.example.solidconnection.siteuser.dto.validation; + +import static com.example.solidconnection.common.exception.ErrorCode.PASSWORD_NOT_CHANGED; +import static com.example.solidconnection.common.exception.ErrorCode.PASSWORD_NOT_CONFIRMED; + +import com.example.solidconnection.siteuser.dto.PasswordUpdateRequest; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.Objects; + +public class PasswordConfirmationValidator implements ConstraintValidator { + + @Override + public boolean isValid(PasswordUpdateRequest request, ConstraintValidatorContext context) { + context.disableDefaultConstraintViolation(); + + if (isNewPasswordNotConfirmed(request)) { + addConstraintViolation(context, PASSWORD_NOT_CONFIRMED.getMessage(), "newPasswordConfirmation"); + + return false; + } + + if (isPasswordUnchanged(request)) { + addConstraintViolation(context, PASSWORD_NOT_CHANGED.getMessage(), "newPassword"); + + return false; + } + + return true; + } + + private boolean isNewPasswordNotConfirmed(PasswordUpdateRequest request) { + return !Objects.equals(request.newPassword(), request.newPasswordConfirmation()); + } + + private boolean isPasswordUnchanged(PasswordUpdateRequest request) { + return Objects.equals(request.currentPassword(), request.newPassword()); + } + + private void addConstraintViolation(ConstraintValidatorContext context, String message, String propertyName) { + context.buildConstraintViolationWithTemplate(message) + .addPropertyNode(propertyName) + .addConstraintViolation(); + } +} diff --git a/src/main/java/com/example/solidconnection/siteuser/service/MyPageService.java b/src/main/java/com/example/solidconnection/siteuser/service/MyPageService.java index 7b85bd411..1800df32b 100644 --- a/src/main/java/com/example/solidconnection/siteuser/service/MyPageService.java +++ b/src/main/java/com/example/solidconnection/siteuser/service/MyPageService.java @@ -2,6 +2,7 @@ import static com.example.solidconnection.common.exception.ErrorCode.CAN_NOT_CHANGE_NICKNAME_YET; import static com.example.solidconnection.common.exception.ErrorCode.NICKNAME_ALREADY_EXISTED; +import static com.example.solidconnection.common.exception.ErrorCode.PASSWORD_MISMATCH; import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; import com.example.solidconnection.common.exception.CustomException; @@ -10,11 +11,13 @@ import com.example.solidconnection.s3.service.S3Service; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.dto.MyPageResponse; +import com.example.solidconnection.siteuser.dto.PasswordUpdateRequest; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import com.example.solidconnection.university.repository.LikedUnivApplyInfoRepository; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -26,6 +29,7 @@ public class MyPageService { public static final int MIN_DAYS_BETWEEN_NICKNAME_CHANGES = 7; public static final DateTimeFormatter NICKNAME_LAST_CHANGE_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + private final PasswordEncoder passwordEncoder; private final SiteUserRepository siteUserRepository; private final LikedUnivApplyInfoRepository likedUnivApplyInfoRepository; private final S3Service s3Service; @@ -87,4 +91,21 @@ private boolean isDefaultProfileImage(String profileImageUrl) { String prefix = "profile/"; return profileImageUrl == null || !profileImageUrl.startsWith(prefix); } + + @Transactional + public void updatePassword(long siteUserId, PasswordUpdateRequest request) { + SiteUser user = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + + // 사용자의 비밀번호와 request의 currentPassword가 동일한지 검증 + validatePasswordMatch(request.currentPassword(), user.getPassword()); + + user.updatePassword(passwordEncoder.encode(request.newPassword())); + } + + private void validatePasswordMatch(String currentPassword, String userPassword) { + if (!passwordEncoder.matches(currentPassword, userPassword)) { + throw new CustomException(PASSWORD_MISMATCH); + } + } } diff --git a/src/test/java/com/example/solidconnection/siteuser/dto/validation/PasswordConfirmationValidatorTest.java b/src/test/java/com/example/solidconnection/siteuser/dto/validation/PasswordConfirmationValidatorTest.java new file mode 100644 index 000000000..c3c69f8fc --- /dev/null +++ b/src/test/java/com/example/solidconnection/siteuser/dto/validation/PasswordConfirmationValidatorTest.java @@ -0,0 +1,76 @@ +package com.example.solidconnection.siteuser.dto.validation; + +import static com.example.solidconnection.common.exception.ErrorCode.PASSWORD_NOT_CHANGED; +import static com.example.solidconnection.common.exception.ErrorCode.PASSWORD_NOT_CONFIRMED; +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.solidconnection.siteuser.dto.PasswordUpdateRequest; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("비밀번호 변경 유효성 검사 테스트") +class PasswordConfirmationValidatorTest { + + private static final String MESSAGE = "message"; + + private Validator validator; + + @BeforeEach + void setUp() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + void 유효한_비밀번호_변경_요청은_검증을_통과한다() { + // given + PasswordUpdateRequest request = new PasswordUpdateRequest("currentPassword123", "newPassword123!", "newPassword123!"); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isEmpty(); + } + + @Nested + class 유효하지_않은_비밀번호_변경_테스트 { + + @Test + void 새로운_비밀번호와_확인_비밀번호가_일치하지_않으면_검증에_실패한다() { + // given + PasswordUpdateRequest request = new PasswordUpdateRequest("currentPassword123", "newPassword123!", "differentPassword123!"); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations) + .isNotEmpty() + .extracting(MESSAGE) + .contains(PASSWORD_NOT_CONFIRMED.getMessage()); + } + + @Test + void 현재_비밀번호와_새로운_비밀번호가_같으면_검증에_실패한다() { + // given + PasswordUpdateRequest request = new PasswordUpdateRequest("currentPassword123", "currentPassword123", "currentPassword123"); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations) + .isNotEmpty() + .extracting(MESSAGE) + .contains(PASSWORD_NOT_CHANGED.getMessage()); + } + } +} diff --git a/src/test/java/com/example/solidconnection/siteuser/service/MyPageServiceTest.java b/src/test/java/com/example/solidconnection/siteuser/service/MyPageServiceTest.java index 0358f8024..f470e9cfc 100644 --- a/src/test/java/com/example/solidconnection/siteuser/service/MyPageServiceTest.java +++ b/src/test/java/com/example/solidconnection/siteuser/service/MyPageServiceTest.java @@ -1,10 +1,13 @@ package com.example.solidconnection.siteuser.service; import static com.example.solidconnection.common.exception.ErrorCode.CAN_NOT_CHANGE_NICKNAME_YET; +import static com.example.solidconnection.common.exception.ErrorCode.PASSWORD_MISMATCH; import static com.example.solidconnection.siteuser.service.MyPageService.MIN_DAYS_BETWEEN_NICKNAME_CHANGES; import static com.example.solidconnection.siteuser.service.MyPageService.NICKNAME_LAST_CHANGE_DATE_FORMAT; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.mockito.BDDMockito.any; import static org.mockito.BDDMockito.eq; import static org.mockito.BDDMockito.given; @@ -19,6 +22,7 @@ import com.example.solidconnection.siteuser.domain.Role; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.dto.MyPageResponse; +import com.example.solidconnection.siteuser.dto.PasswordUpdateRequest; import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.siteuser.fixture.SiteUserFixtureBuilder; import com.example.solidconnection.siteuser.repository.SiteUserRepository; @@ -27,7 +31,6 @@ import com.example.solidconnection.university.fixture.UnivApplyInfoFixture; import com.example.solidconnection.university.repository.LikedUnivApplyInfoRepository; import java.time.LocalDateTime; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -35,6 +38,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.crypto.password.PasswordEncoder; @TestContainerSpringBootTest @DisplayName("마이페이지 서비스 테스트") @@ -61,6 +65,9 @@ class MyPageServiceTest { @Autowired private SiteUserFixtureBuilder siteUserFixtureBuilder; + @Autowired + private PasswordEncoder passwordEncoder; + private SiteUser user; @BeforeEach @@ -77,7 +84,7 @@ void setUp() { MyPageResponse response = myPageService.getMyPageInfo(user.getId()); // then - Assertions.assertAll( + assertAll( () -> assertThat(response.nickname()).isEqualTo(user.getNickname()), () -> assertThat(response.profileImageUrl()).isEqualTo(user.getProfileImageUrl()), () -> assertThat(response.role()).isEqualTo(user.getRole()), @@ -176,6 +183,50 @@ void setUp() { } } + @Nested + class 비밀번호_변경_테스트 { + + private String currentPassword; + private String newPassword; + + @BeforeEach + void setUp() { + currentPassword = "currentPassword123"; + newPassword = "newPassword123"; + + user.updatePassword(passwordEncoder.encode(currentPassword)); + siteUserRepository.save(user); + } + + @Test + void 비밀번호를_성공적으로_변경한다() { + // given + PasswordUpdateRequest request = new PasswordUpdateRequest(currentPassword, newPassword, newPassword); + + // when + myPageService.updatePassword(user.getId(), request); + + // then + SiteUser updatedUser = siteUserRepository.findById(user.getId()).get(); + assertAll( + () -> assertThat(passwordEncoder.matches(newPassword, updatedUser.getPassword())).isTrue(), + () -> assertThat(passwordEncoder.matches(currentPassword, updatedUser.getPassword())).isFalse() + ); + } + + @Test + void 현재_비밀번호가_일치하지_않으면_예외가_발생한다() { + // given + String wrongPassword = "wrongPassword"; + PasswordUpdateRequest request = new PasswordUpdateRequest(wrongPassword, newPassword, newPassword); + + // when & then + assertThatThrownBy(() -> myPageService.updatePassword(user.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(PASSWORD_MISMATCH.getMessage()); + } + } + private int createLikedUnivApplyInfos(SiteUser testUser) { LikedUnivApplyInfo likedUnivApplyInfo1 = new LikedUnivApplyInfo(null, univApplyInfoFixture.괌대학_A_지원_정보().getId(), testUser.getId()); LikedUnivApplyInfo likedUnivApplyInfo2 = new LikedUnivApplyInfo(null, univApplyInfoFixture.메이지대학_지원_정보().getId(), testUser.getId());