Skip to content

Commit 1f8e455

Browse files
whqtkerGyuhyeok99
andauthored
feat: 비밀번호 변경 API 구현 (#448)
* feat: 비밀번호 변경 DTO 작성 * feat: 비밀번호 변경 검증 관련 어노테이션 작성 * feat: 비밀번호 변경 API 구현 * test: 비밀번호 변경 관련 테스트 코드 작성 * chore: 코드 리포매팅 * test: 검증 방식 변경 * chore: Password 어노테이션 추가 * chore: 명확하게 의미 전달이 가능한 변수명으로 수정 * test: 테스트 코드 수정 * test: 실제 비밀번호 변경 플로우에 맞도록 테스트 코드 수정 * chore: matches 메서드 정의에 맞게 파라미터 수정 * chore: 역할을 더 잘 드러내도록 검증 메서드명 변경 * feat: 비밀번호 검증 추가 - 비밀번호는 영문, 숫자, 특수문자를 포함한 8자리 이상 * fix: Password 어노테이션 import문 추가 * fix: currentPassword가 인코딩되었던 문제 해결 --------- Co-authored-by: Gyuhyeok99 <ghkdrbgur13@naver.com>
1 parent 526e507 commit 1f8e455

9 files changed

Lines changed: 254 additions & 2 deletions

File tree

src/main/java/com/example/solidconnection/common/exception/ErrorCode.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ public enum ErrorCode {
5656
ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "액세스 토큰이 만료되었습니다. 재발급 api를 호출해주세요."),
5757
REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "리프레시 토큰이 만료되었습니다. 다시 로그인을 진행해주세요."),
5858
ACCESS_DENIED(HttpStatus.FORBIDDEN.value(), "접근 권한이 없습니다."),
59+
PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST.value(), "비밀번호가 일치하지 않습니다."),
60+
PASSWORD_NOT_CHANGED(HttpStatus.BAD_REQUEST.value(), "현재 비밀번호와 새 비밀번호가 동일합니다."),
61+
PASSWORD_NOT_CONFIRMED(HttpStatus.BAD_REQUEST.value(), "새 비밀번호가 일치하지 않습니다."),
5962
SIGN_IN_FAILED(HttpStatus.UNAUTHORIZED.value(), "로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요."),
6063

6164
// s3

src/main/java/com/example/solidconnection/siteuser/controller/MyPageController.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33

44
import com.example.solidconnection.common.resolver.AuthorizedUser;
55
import com.example.solidconnection.siteuser.dto.MyPageResponse;
6+
import com.example.solidconnection.siteuser.dto.PasswordUpdateRequest;
67
import com.example.solidconnection.siteuser.service.MyPageService;
8+
import jakarta.validation.Valid;
79
import lombok.RequiredArgsConstructor;
810
import org.springframework.http.ResponseEntity;
911
import org.springframework.web.bind.annotation.GetMapping;
1012
import org.springframework.web.bind.annotation.PatchMapping;
13+
import org.springframework.web.bind.annotation.RequestBody;
1114
import org.springframework.web.bind.annotation.RequestMapping;
1215
import org.springframework.web.bind.annotation.RequestParam;
1316
import org.springframework.web.bind.annotation.RestController;
@@ -37,4 +40,13 @@ public ResponseEntity<Void> updateMyPageInfo(
3740
myPageService.updateMyPageInfo(siteUserId, imageFile, nickname);
3841
return ResponseEntity.ok().build();
3942
}
43+
44+
@PatchMapping("/password")
45+
public ResponseEntity<Void> updatePassword(
46+
@AuthorizedUser long siteUserId,
47+
@RequestBody @Valid PasswordUpdateRequest request
48+
) {
49+
myPageService.updatePassword(siteUserId, request);
50+
return ResponseEntity.ok().build();
51+
}
4052
}

src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,8 @@ public SiteUser(
115115
this.authType = authType;
116116
this.password = password;
117117
}
118+
119+
public void updatePassword(String newEncodedPassword) {
120+
this.password = newEncodedPassword;
121+
}
118122
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.example.solidconnection.siteuser.dto;
2+
3+
import com.example.solidconnection.auth.dto.validation.Password;
4+
import com.example.solidconnection.siteuser.dto.validation.PasswordConfirmation;
5+
import jakarta.validation.constraints.NotBlank;
6+
7+
@PasswordConfirmation
8+
public record PasswordUpdateRequest(
9+
@NotBlank(message = "현재 비밀번호를 입력해주세요.")
10+
String currentPassword,
11+
12+
@NotBlank(message = "새 비밀번호를 입력해주세요.")
13+
@Password
14+
String newPassword,
15+
16+
@NotBlank(message = "새 비밀번호를 다시 한번 입력해주세요.")
17+
String newPasswordConfirmation
18+
) {
19+
20+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.example.solidconnection.siteuser.dto.validation;
2+
3+
import jakarta.validation.Constraint;
4+
import jakarta.validation.Payload;
5+
import java.lang.annotation.ElementType;
6+
import java.lang.annotation.Retention;
7+
import java.lang.annotation.RetentionPolicy;
8+
import java.lang.annotation.Target;
9+
10+
@Constraint(validatedBy = PasswordConfirmationValidator.class)
11+
@Target({ElementType.TYPE})
12+
@Retention(RetentionPolicy.RUNTIME)
13+
public @interface PasswordConfirmation {
14+
15+
String message() default "비밀번호 변경 과정에서 오류가 발생했습니다.";
16+
17+
Class<?>[] groups() default {};
18+
19+
Class<? extends Payload>[] payload() default {};
20+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.example.solidconnection.siteuser.dto.validation;
2+
3+
import static com.example.solidconnection.common.exception.ErrorCode.PASSWORD_NOT_CHANGED;
4+
import static com.example.solidconnection.common.exception.ErrorCode.PASSWORD_NOT_CONFIRMED;
5+
6+
import com.example.solidconnection.siteuser.dto.PasswordUpdateRequest;
7+
import jakarta.validation.ConstraintValidator;
8+
import jakarta.validation.ConstraintValidatorContext;
9+
import java.util.Objects;
10+
11+
public class PasswordConfirmationValidator implements ConstraintValidator<PasswordConfirmation, PasswordUpdateRequest> {
12+
13+
@Override
14+
public boolean isValid(PasswordUpdateRequest request, ConstraintValidatorContext context) {
15+
context.disableDefaultConstraintViolation();
16+
17+
if (isNewPasswordNotConfirmed(request)) {
18+
addConstraintViolation(context, PASSWORD_NOT_CONFIRMED.getMessage(), "newPasswordConfirmation");
19+
20+
return false;
21+
}
22+
23+
if (isPasswordUnchanged(request)) {
24+
addConstraintViolation(context, PASSWORD_NOT_CHANGED.getMessage(), "newPassword");
25+
26+
return false;
27+
}
28+
29+
return true;
30+
}
31+
32+
private boolean isNewPasswordNotConfirmed(PasswordUpdateRequest request) {
33+
return !Objects.equals(request.newPassword(), request.newPasswordConfirmation());
34+
}
35+
36+
private boolean isPasswordUnchanged(PasswordUpdateRequest request) {
37+
return Objects.equals(request.currentPassword(), request.newPassword());
38+
}
39+
40+
private void addConstraintViolation(ConstraintValidatorContext context, String message, String propertyName) {
41+
context.buildConstraintViolationWithTemplate(message)
42+
.addPropertyNode(propertyName)
43+
.addConstraintViolation();
44+
}
45+
}

src/main/java/com/example/solidconnection/siteuser/service/MyPageService.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import static com.example.solidconnection.common.exception.ErrorCode.CAN_NOT_CHANGE_NICKNAME_YET;
44
import static com.example.solidconnection.common.exception.ErrorCode.NICKNAME_ALREADY_EXISTED;
5+
import static com.example.solidconnection.common.exception.ErrorCode.PASSWORD_MISMATCH;
56
import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND;
67

78
import com.example.solidconnection.common.exception.CustomException;
@@ -10,11 +11,13 @@
1011
import com.example.solidconnection.s3.service.S3Service;
1112
import com.example.solidconnection.siteuser.domain.SiteUser;
1213
import com.example.solidconnection.siteuser.dto.MyPageResponse;
14+
import com.example.solidconnection.siteuser.dto.PasswordUpdateRequest;
1315
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
1416
import com.example.solidconnection.university.repository.LikedUnivApplyInfoRepository;
1517
import java.time.LocalDateTime;
1618
import java.time.format.DateTimeFormatter;
1719
import lombok.RequiredArgsConstructor;
20+
import org.springframework.security.crypto.password.PasswordEncoder;
1821
import org.springframework.stereotype.Service;
1922
import org.springframework.transaction.annotation.Transactional;
2023
import org.springframework.web.multipart.MultipartFile;
@@ -26,6 +29,7 @@ public class MyPageService {
2629
public static final int MIN_DAYS_BETWEEN_NICKNAME_CHANGES = 7;
2730
public static final DateTimeFormatter NICKNAME_LAST_CHANGE_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
2831

32+
private final PasswordEncoder passwordEncoder;
2933
private final SiteUserRepository siteUserRepository;
3034
private final LikedUnivApplyInfoRepository likedUnivApplyInfoRepository;
3135
private final S3Service s3Service;
@@ -87,4 +91,21 @@ private boolean isDefaultProfileImage(String profileImageUrl) {
8791
String prefix = "profile/";
8892
return profileImageUrl == null || !profileImageUrl.startsWith(prefix);
8993
}
94+
95+
@Transactional
96+
public void updatePassword(long siteUserId, PasswordUpdateRequest request) {
97+
SiteUser user = siteUserRepository.findById(siteUserId)
98+
.orElseThrow(() -> new CustomException(USER_NOT_FOUND));
99+
100+
// 사용자의 비밀번호와 request의 currentPassword가 동일한지 검증
101+
validatePasswordMatch(request.currentPassword(), user.getPassword());
102+
103+
user.updatePassword(passwordEncoder.encode(request.newPassword()));
104+
}
105+
106+
private void validatePasswordMatch(String currentPassword, String userPassword) {
107+
if (!passwordEncoder.matches(currentPassword, userPassword)) {
108+
throw new CustomException(PASSWORD_MISMATCH);
109+
}
110+
}
90111
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package com.example.solidconnection.siteuser.dto.validation;
2+
3+
import static com.example.solidconnection.common.exception.ErrorCode.PASSWORD_NOT_CHANGED;
4+
import static com.example.solidconnection.common.exception.ErrorCode.PASSWORD_NOT_CONFIRMED;
5+
import static org.assertj.core.api.Assertions.assertThat;
6+
7+
import com.example.solidconnection.siteuser.dto.PasswordUpdateRequest;
8+
import jakarta.validation.ConstraintViolation;
9+
import jakarta.validation.Validation;
10+
import jakarta.validation.Validator;
11+
import jakarta.validation.ValidatorFactory;
12+
import java.util.Set;
13+
import org.junit.jupiter.api.BeforeEach;
14+
import org.junit.jupiter.api.DisplayName;
15+
import org.junit.jupiter.api.Nested;
16+
import org.junit.jupiter.api.Test;
17+
18+
@DisplayName("비밀번호 변경 유효성 검사 테스트")
19+
class PasswordConfirmationValidatorTest {
20+
21+
private static final String MESSAGE = "message";
22+
23+
private Validator validator;
24+
25+
@BeforeEach
26+
void setUp() {
27+
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
28+
validator = factory.getValidator();
29+
}
30+
31+
@Test
32+
void 유효한_비밀번호_변경_요청은_검증을_통과한다() {
33+
// given
34+
PasswordUpdateRequest request = new PasswordUpdateRequest("currentPassword123", "newPassword123!", "newPassword123!");
35+
36+
// when
37+
Set<ConstraintViolation<PasswordUpdateRequest>> violations = validator.validate(request);
38+
39+
// then
40+
assertThat(violations).isEmpty();
41+
}
42+
43+
@Nested
44+
class 유효하지_않은_비밀번호_변경_테스트 {
45+
46+
@Test
47+
void 새로운_비밀번호와_확인_비밀번호가_일치하지_않으면_검증에_실패한다() {
48+
// given
49+
PasswordUpdateRequest request = new PasswordUpdateRequest("currentPassword123", "newPassword123!", "differentPassword123!");
50+
51+
// when
52+
Set<ConstraintViolation<PasswordUpdateRequest>> violations = validator.validate(request);
53+
54+
// then
55+
assertThat(violations)
56+
.isNotEmpty()
57+
.extracting(MESSAGE)
58+
.contains(PASSWORD_NOT_CONFIRMED.getMessage());
59+
}
60+
61+
@Test
62+
void 현재_비밀번호와_새로운_비밀번호가_같으면_검증에_실패한다() {
63+
// given
64+
PasswordUpdateRequest request = new PasswordUpdateRequest("currentPassword123", "currentPassword123", "currentPassword123");
65+
66+
// when
67+
Set<ConstraintViolation<PasswordUpdateRequest>> violations = validator.validate(request);
68+
69+
// then
70+
assertThat(violations)
71+
.isNotEmpty()
72+
.extracting(MESSAGE)
73+
.contains(PASSWORD_NOT_CHANGED.getMessage());
74+
}
75+
}
76+
}

src/test/java/com/example/solidconnection/siteuser/service/MyPageServiceTest.java

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package com.example.solidconnection.siteuser.service;
22

33
import static com.example.solidconnection.common.exception.ErrorCode.CAN_NOT_CHANGE_NICKNAME_YET;
4+
import static com.example.solidconnection.common.exception.ErrorCode.PASSWORD_MISMATCH;
45
import static com.example.solidconnection.siteuser.service.MyPageService.MIN_DAYS_BETWEEN_NICKNAME_CHANGES;
56
import static com.example.solidconnection.siteuser.service.MyPageService.NICKNAME_LAST_CHANGE_DATE_FORMAT;
67
import static org.assertj.core.api.Assertions.assertThat;
8+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
79
import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode;
10+
import static org.junit.jupiter.api.Assertions.assertAll;
811
import static org.mockito.BDDMockito.any;
912
import static org.mockito.BDDMockito.eq;
1013
import static org.mockito.BDDMockito.given;
@@ -19,6 +22,7 @@
1922
import com.example.solidconnection.siteuser.domain.Role;
2023
import com.example.solidconnection.siteuser.domain.SiteUser;
2124
import com.example.solidconnection.siteuser.dto.MyPageResponse;
25+
import com.example.solidconnection.siteuser.dto.PasswordUpdateRequest;
2226
import com.example.solidconnection.siteuser.fixture.SiteUserFixture;
2327
import com.example.solidconnection.siteuser.fixture.SiteUserFixtureBuilder;
2428
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
@@ -27,14 +31,14 @@
2731
import com.example.solidconnection.university.fixture.UnivApplyInfoFixture;
2832
import com.example.solidconnection.university.repository.LikedUnivApplyInfoRepository;
2933
import java.time.LocalDateTime;
30-
import org.junit.jupiter.api.Assertions;
3134
import org.junit.jupiter.api.BeforeEach;
3235
import org.junit.jupiter.api.DisplayName;
3336
import org.junit.jupiter.api.Nested;
3437
import org.junit.jupiter.api.Test;
3538
import org.springframework.beans.factory.annotation.Autowired;
3639
import org.springframework.boot.test.mock.mockito.MockBean;
3740
import org.springframework.mock.web.MockMultipartFile;
41+
import org.springframework.security.crypto.password.PasswordEncoder;
3842

3943
@TestContainerSpringBootTest
4044
@DisplayName("마이페이지 서비스 테스트")
@@ -61,6 +65,9 @@ class MyPageServiceTest {
6165
@Autowired
6266
private SiteUserFixtureBuilder siteUserFixtureBuilder;
6367

68+
@Autowired
69+
private PasswordEncoder passwordEncoder;
70+
6471
private SiteUser user;
6572

6673
@BeforeEach
@@ -77,7 +84,7 @@ void setUp() {
7784
MyPageResponse response = myPageService.getMyPageInfo(user.getId());
7885

7986
// then
80-
Assertions.assertAll(
87+
assertAll(
8188
() -> assertThat(response.nickname()).isEqualTo(user.getNickname()),
8289
() -> assertThat(response.profileImageUrl()).isEqualTo(user.getProfileImageUrl()),
8390
() -> assertThat(response.role()).isEqualTo(user.getRole()),
@@ -176,6 +183,50 @@ void setUp() {
176183
}
177184
}
178185

186+
@Nested
187+
class 비밀번호_변경_테스트 {
188+
189+
private String currentPassword;
190+
private String newPassword;
191+
192+
@BeforeEach
193+
void setUp() {
194+
currentPassword = "currentPassword123";
195+
newPassword = "newPassword123";
196+
197+
user.updatePassword(passwordEncoder.encode(currentPassword));
198+
siteUserRepository.save(user);
199+
}
200+
201+
@Test
202+
void 비밀번호를_성공적으로_변경한다() {
203+
// given
204+
PasswordUpdateRequest request = new PasswordUpdateRequest(currentPassword, newPassword, newPassword);
205+
206+
// when
207+
myPageService.updatePassword(user.getId(), request);
208+
209+
// then
210+
SiteUser updatedUser = siteUserRepository.findById(user.getId()).get();
211+
assertAll(
212+
() -> assertThat(passwordEncoder.matches(newPassword, updatedUser.getPassword())).isTrue(),
213+
() -> assertThat(passwordEncoder.matches(currentPassword, updatedUser.getPassword())).isFalse()
214+
);
215+
}
216+
217+
@Test
218+
void 현재_비밀번호가_일치하지_않으면_예외가_발생한다() {
219+
// given
220+
String wrongPassword = "wrongPassword";
221+
PasswordUpdateRequest request = new PasswordUpdateRequest(wrongPassword, newPassword, newPassword);
222+
223+
// when & then
224+
assertThatThrownBy(() -> myPageService.updatePassword(user.getId(), request))
225+
.isInstanceOf(CustomException.class)
226+
.hasMessage(PASSWORD_MISMATCH.getMessage());
227+
}
228+
}
229+
179230
private int createLikedUnivApplyInfos(SiteUser testUser) {
180231
LikedUnivApplyInfo likedUnivApplyInfo1 = new LikedUnivApplyInfo(null, univApplyInfoFixture.괌대학_A_지원_정보().getId(), testUser.getId());
181232
LikedUnivApplyInfo likedUnivApplyInfo2 = new LikedUnivApplyInfo(null, univApplyInfoFixture.메이지대학_지원_정보().getId(), testUser.getId());

0 commit comments

Comments
 (0)