From 4a332e4e1b678dc0cc49bd4e34d4c418cec0fc10 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Mon, 29 Sep 2025 10:27:04 +0900 Subject: [PATCH 1/4] =?UTF-8?q?Feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 17 +++++++++-- .../user/dto/ChangePasswordRequest.java | 15 ++++++++++ .../back/domain/user/service/AuthService.java | 15 ++-------- .../back/domain/user/service/UserService.java | 28 +++++++++++++++++++ .../back/global/util/PasswordValidator.java | 28 +++++++++++++++++++ 5 files changed, 88 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/back/domain/user/dto/ChangePasswordRequest.java create mode 100644 src/main/java/com/back/global/util/PasswordValidator.java diff --git a/src/main/java/com/back/domain/user/controller/UserController.java b/src/main/java/com/back/domain/user/controller/UserController.java index 9f48d2d6..5a1f6a86 100644 --- a/src/main/java/com/back/domain/user/controller/UserController.java +++ b/src/main/java/com/back/domain/user/controller/UserController.java @@ -1,5 +1,6 @@ package com.back.domain.user.controller; +import com.back.domain.user.dto.ChangePasswordRequest; import com.back.domain.user.dto.UpdateUserProfileRequest; import com.back.domain.user.dto.UserDetailResponse; import com.back.domain.user.service.UserService; @@ -41,8 +42,20 @@ public ResponseEntity> updateMyProfile( .ok(RsData.success( "회원 정보를 수정했습니다.", updated - ) - ); + )); + } + + // 내 비밀번호 변경 + @PatchMapping("/me/password") + public ResponseEntity> changeMyPassword( + @AuthenticationPrincipal CustomUserDetails user, + @Valid @RequestBody ChangePasswordRequest request + ) { + userService.changePassword(user.getUserId(), request); + return ResponseEntity + .ok(RsData.success( + "비밀번호가 변경되었습니다." + )); } // 내 계정 삭제 diff --git a/src/main/java/com/back/domain/user/dto/ChangePasswordRequest.java b/src/main/java/com/back/domain/user/dto/ChangePasswordRequest.java new file mode 100644 index 00000000..c674dbab --- /dev/null +++ b/src/main/java/com/back/domain/user/dto/ChangePasswordRequest.java @@ -0,0 +1,15 @@ +package com.back.domain.user.dto; + +import jakarta.validation.constraints.NotBlank; + +/** + * 비밀번호 변경 요청을 나타내는 DTO + * + * @param currentPassword 현재 비밀번호 + * @param newPassword 새로운 비밀번호 + */ +public record ChangePasswordRequest( + @NotBlank String currentPassword, + @NotBlank String newPassword +) { +} diff --git a/src/main/java/com/back/domain/user/service/AuthService.java b/src/main/java/com/back/domain/user/service/AuthService.java index d3d6ff3e..4c3581eb 100644 --- a/src/main/java/com/back/domain/user/service/AuthService.java +++ b/src/main/java/com/back/domain/user/service/AuthService.java @@ -15,6 +15,7 @@ import com.back.global.exception.ErrorCode; import com.back.global.security.jwt.JwtTokenProvider; import com.back.global.util.CookieUtil; +import com.back.global.util.PasswordValidator; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -48,7 +49,7 @@ public UserResponse register(UserRegisterRequest request) { validateDuplicate(request); // 비밀번호 정책 검증 - validatePasswordPolicy(request.password()); + PasswordValidator.validate(request.password()); // User 엔티티 생성 (기본 Role.USER, Status.PENDING) User user = User.createUser( @@ -216,18 +217,6 @@ private void validateDuplicate(UserRegisterRequest request) { } } - /** - * 비밀번호 정책 검증 - * - 최소 8자 이상 - * - 숫자 및 특수문자 반드시 포함 - */ - private void validatePasswordPolicy(String password) { - String regex = "^(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$"; - if (!password.matches(regex)) { - throw new CustomException(ErrorCode.INVALID_PASSWORD); - } - } - /** * 쿠키에서 Refresh Token 추출 */ diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index ce5661ca..76311dce 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -1,5 +1,6 @@ package com.back.domain.user.service; +import com.back.domain.user.dto.ChangePasswordRequest; import com.back.domain.user.dto.UpdateUserProfileRequest; import com.back.domain.user.dto.UserDetailResponse; import com.back.domain.user.entity.User; @@ -9,7 +10,9 @@ import com.back.domain.user.repository.UserRepository; import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; +import com.back.global.util.PasswordValidator; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,6 +22,7 @@ public class UserService { private final UserRepository userRepository; private final UserProfileRepository userProfileRepository; + private final PasswordEncoder passwordEncoder; /** * 사용자 정보 조회 서비스 @@ -62,6 +66,30 @@ public UserDetailResponse updateUserProfile(Long userId, UpdateUserProfileReques return UserDetailResponse.from(user); } + /** + * 비밀번호 변경 서비스 + * 1. 사용자 조회 및 상태 검증 + * 2. 현재 비밀번호 검증 + * 3. 새 비밀번호 정책 검증 + * 4. 비밀번호 변경 + */ + public void changePassword(Long userId, ChangePasswordRequest request) { + + // 사용자 조회 및 상태 검증 + User user = getValidUser(userId); + + // 현재 비밀번호 검증 + if (!passwordEncoder.matches(request.currentPassword(), user.getPassword())) { + throw new CustomException(ErrorCode.INVALID_CREDENTIALS); + } + + // 새 비밀번호 정책 검증 + PasswordValidator.validate(request.newPassword()); + + // 비밀번호 변경 + user.setPassword(passwordEncoder.encode(request.newPassword())); + } + /** * 사용자 탈퇴 서비스 (soft delete) * 1. 사용자 조회 및 상태 검증 diff --git a/src/main/java/com/back/global/util/PasswordValidator.java b/src/main/java/com/back/global/util/PasswordValidator.java new file mode 100644 index 00000000..fd5815ab --- /dev/null +++ b/src/main/java/com/back/global/util/PasswordValidator.java @@ -0,0 +1,28 @@ +package com.back.global.util; + +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; + +/** + * 비밀번호 유효성 검증 유틸리티 클래스 + * + * 비밀번호 정책: + * - 최소 8자 이상 + * - 최소 하나의 숫자 포함 + * - 최소 하나의 특수문자 포함 (!@#$%^&*) + */ +public class PasswordValidator { + private static final String PASSWORD_REGEX = "^(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$"; + + /** + * 비밀번호 유효성 검증 메서드 + * + * @param password 검증할 비밀번호 + * @throws CustomException 비밀번호가 정책에 맞지 않을 경우 USER_005 예외 발생 + */ + public static void validate(String password) { + if (!password.matches(PASSWORD_REGEX)) { + throw new CustomException(ErrorCode.INVALID_PASSWORD); + } + } +} From 7c0e236cedb9047ab4c39fe198f7ed9ad64067ff Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Mon, 29 Sep 2025 10:30:51 +0900 Subject: [PATCH 2/4] =?UTF-8?q?Docs:=20Swagger=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserControllerDocs.java | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/src/main/java/com/back/domain/user/controller/UserControllerDocs.java b/src/main/java/com/back/domain/user/controller/UserControllerDocs.java index 3963c7aa..7a79c679 100644 --- a/src/main/java/com/back/domain/user/controller/UserControllerDocs.java +++ b/src/main/java/com/back/domain/user/controller/UserControllerDocs.java @@ -1,5 +1,6 @@ package com.back.domain.user.controller; +import com.back.domain.user.dto.ChangePasswordRequest; import com.back.domain.user.dto.UpdateUserProfileRequest; import com.back.domain.user.dto.UserDetailResponse; import com.back.global.common.dto.RsData; @@ -304,6 +305,149 @@ ResponseEntity> updateMyProfile( @Valid @RequestBody UpdateUserProfileRequest request ); + @Operation( + summary = "비밀번호 변경", + description = "로그인한 사용자가 본인 계정의 비밀번호를 변경합니다. " + + "현재 비밀번호를 검증하고, 새 비밀번호는 정책(최소 8자, 숫자/특수문자 포함)을 만족해야 합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "비밀번호 변경 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "비밀번호가 변경되었습니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "400", + description = "새 비밀번호 정책 위반", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_005", + "message": "비밀번호는 최소 8자 이상, 숫자/특수문자를 포함해야 합니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "403", + description = "정지된 계정", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_008", + "message": "정지된 계정입니다. 관리자에게 문의하세요.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "410", + description = "탈퇴한 계정", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_009", + "message": "탈퇴한 계정입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (토큰 없음/잘못됨/만료 또는 현재 비밀번호 불일치)", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "토큰 없음", value = """ + { + "success": false, + "code": "AUTH_001", + "message": "인증이 필요합니다.", + "data": null + } + """), + @ExampleObject(name = "잘못된 토큰", value = """ + { + "success": false, + "code": "AUTH_002", + "message": "유효하지 않은 액세스 토큰입니다.", + "data": null + } + """), + @ExampleObject(name = "만료된 토큰", value = """ + { + "success": false, + "code": "AUTH_004", + "message": "만료된 액세스 토큰입니다.", + "data": null + } + """), + @ExampleObject(name = "현재 비밀번호 불일치", value = """ + { + "success": false, + "code": "USER_006", + "message": "아이디 또는 비밀번호가 올바르지 않습니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 사용자", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_001", + "message": "존재하지 않는 사용자입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity> changeMyPassword( + @AuthenticationPrincipal CustomUserDetails user, + @Valid @RequestBody ChangePasswordRequest request + ); + @Operation( summary = "내 계정 삭제", description = "로그인한 사용자의 계정을 탈퇴 처리합니다. " + From 28aced9630f83444ec1da2ad10a9ad2f5a17fae3 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Mon, 29 Sep 2025 10:37:48 +0900 Subject: [PATCH 3/4] =?UTF-8?q?Test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserControllerTest.java | 183 ++++++++++++++++++ .../domain/user/service/UserServiceTest.java | 101 ++++++++++ 2 files changed, 284 insertions(+) diff --git a/src/test/java/com/back/domain/user/controller/UserControllerTest.java b/src/test/java/com/back/domain/user/controller/UserControllerTest.java index de63c18b..a5da7c4c 100644 --- a/src/test/java/com/back/domain/user/controller/UserControllerTest.java +++ b/src/test/java/com/back/domain/user/controller/UserControllerTest.java @@ -1,5 +1,6 @@ package com.back.domain.user.controller; +import com.back.domain.user.dto.ChangePasswordRequest; import com.back.domain.user.dto.UpdateUserProfileRequest; import com.back.domain.user.entity.User; import com.back.domain.user.entity.UserProfile; @@ -333,6 +334,188 @@ void updateMyProfile_expiredAccessToken() throws Exception { .andExpect(jsonPath("$.message").value("만료된 액세스 토큰입니다.")); } + // ====================== 내 비밀번호 변경 테스트 ====================== + + @Test + @DisplayName("비밀번호 변경 성공 → 200 OK") + void changePassword_success() throws Exception { + // given + User user = User.createUser("changepw", "changepw@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, "소개글", LocalDate.of(2000, 1, 1), 1000)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + ChangePasswordRequest request = new ChangePasswordRequest("P@ssw0rd!", "NewP@ssw0rd!"); + + // when & then + mvc.perform(patch("/api/users/me/password") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.message").value("비밀번호가 변경되었습니다.")); + + // DB 값 검증 + User updated = userRepository.findById(user.getId()).orElseThrow(); + assertThat(passwordEncoder.matches("NewP@ssw0rd!", updated.getPassword())).isTrue(); + } + + @Test + @DisplayName("현재 비밀번호 불일치 → 401 Unauthorized (USER_006)") + void changePassword_invalidCurrentPassword() throws Exception { + // given + User user = User.createUser("wrongpw", "wrongpw@example.com", passwordEncoder.encode("Correct1!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + ChangePasswordRequest request = new ChangePasswordRequest("Wrong1!", "NewP@ssw0rd!"); + + // when & then + mvc.perform(patch("/api/users/me/password") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("USER_006")) + .andExpect(jsonPath("$.message").value("아이디 또는 비밀번호가 올바르지 않습니다.")); + } + + @Test + @DisplayName("새 비밀번호 정책 위반 → 400 Bad Request (USER_005)") + void changePassword_invalidNewPassword() throws Exception { + // given + User user = User.createUser("invalidpw", "invalidpw@example.com", passwordEncoder.encode("Valid1!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + ChangePasswordRequest request = new ChangePasswordRequest("Valid1!", "short"); + + // when & then + mvc.perform(patch("/api/users/me/password") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("USER_005")) + .andExpect(jsonPath("$.message").value("비밀번호는 최소 8자 이상, 숫자/특수문자를 포함해야 합니다.")); + } + + @Test + @DisplayName("탈퇴 계정 비밀번호 변경 시도 → 410 Gone (USER_009)") + void changePassword_deletedUser() throws Exception { + // given + User user = User.createUser("deletedpw", "deletedpw@example.com", passwordEncoder.encode("Valid1!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.DELETED); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + ChangePasswordRequest request = new ChangePasswordRequest("Valid1!", "NewP@ssw0rd!"); + + // when & then + mvc.perform(patch("/api/users/me/password") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isGone()) + .andExpect(jsonPath("$.code").value("USER_009")) + .andExpect(jsonPath("$.message").value("탈퇴한 계정입니다.")); + } + + @Test + @DisplayName("정지 계정 비밀번호 변경 시도 → 403 Forbidden (USER_008)") + void changePassword_suspendedUser() throws Exception { + // given + User user = User.createUser("suspendedpw", "suspendedpw@example.com", passwordEncoder.encode("Valid1!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.SUSPENDED); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + ChangePasswordRequest request = new ChangePasswordRequest("Valid1!", "NewP@ssw0rd!"); + + // when & then + mvc.perform(patch("/api/users/me/password") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("USER_008")) + .andExpect(jsonPath("$.message").value("정지된 계정입니다. 관리자에게 문의하세요.")); + } + + @Test + @DisplayName("AccessToken 없음으로 비밀번호 변경 시도 → 401 Unauthorized (AUTH_001)") + void changePassword_noAccessToken() throws Exception { + ChangePasswordRequest request = new ChangePasswordRequest("P@ssw0rd!", "NewP@ssw0rd!"); + + mvc.perform(patch("/api/users/me/password") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); + } + + @Test + @DisplayName("잘못된 AccessToken으로 비밀번호 변경 시도 → 401 Unauthorized (AUTH_002)") + void changePassword_invalidAccessToken() throws Exception { + ChangePasswordRequest request = new ChangePasswordRequest("P@ssw0rd!", "NewP@ssw0rd!"); + + mvc.perform(patch("/api/users/me/password") + .header("Authorization", "Bearer invalidToken") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_002")) + .andExpect(jsonPath("$.message").value("유효하지 않은 액세스 토큰입니다.")); + } + + @Test + @DisplayName("만료된 AccessToken으로 비밀번호 변경 시도 → 401 Unauthorized (AUTH_004)") + void changePassword_expiredAccessToken() throws Exception { + // given + User user = User.createUser("expiredpw", "expiredpw@example.com", passwordEncoder.encode("Valid1!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String expiredToken = testJwtTokenProvider.createExpiredAccessToken( + user.getId(), user.getUsername(), user.getRole().name() + ); + + ChangePasswordRequest request = new ChangePasswordRequest("Valid1!", "NewP@ssw0rd!"); + + // when & then + mvc.perform(patch("/api/users/me/password") + .header("Authorization", "Bearer " + expiredToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_004")) + .andExpect(jsonPath("$.message").value("만료된 액세스 토큰입니다.")); + } + // ====================== 내 계정 삭제 테스트 ====================== @Test diff --git a/src/test/java/com/back/domain/user/service/UserServiceTest.java b/src/test/java/com/back/domain/user/service/UserServiceTest.java index 9522ac92..2745d280 100644 --- a/src/test/java/com/back/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/back/domain/user/service/UserServiceTest.java @@ -1,5 +1,6 @@ package com.back.domain.user.service; +import com.back.domain.user.dto.ChangePasswordRequest; import com.back.domain.user.dto.UpdateUserProfileRequest; import com.back.domain.user.dto.UserDetailResponse; import com.back.domain.user.entity.User; @@ -179,6 +180,106 @@ void updateUserProfile_suspendedUser() { .hasMessage(ErrorCode.USER_SUSPENDED.getMessage()); } + // ====================== 비밀번호 변경 테스트 ====================== + + @Test + @DisplayName("비밀번호 변경 성공") + void changePassword_success() { + // given: 정상 유저 저장 + User user = User.createUser("changepw", "changepw@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + ChangePasswordRequest request = new ChangePasswordRequest("P@ssw0rd!", "NewP@ssw0rd!"); + + // when + userService.changePassword(user.getId(), request); + + // then: DB의 비밀번호가 변경되었는지 확인 + User updated = userRepository.findById(user.getId()).orElseThrow(); + assertThat(passwordEncoder.matches("NewP@ssw0rd!", updated.getPassword())).isTrue(); + } + + @Test + @DisplayName("현재 비밀번호 불일치 → INVALID_CREDENTIALS 예외") + void changePassword_invalidCurrentPassword() { + // given + User user = User.createUser("wrongpw", "wrongpw@example.com", passwordEncoder.encode("Correct1!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + ChangePasswordRequest request = new ChangePasswordRequest("Wrong1!", "NewP@ssw0rd!"); + + // when & then + assertThatThrownBy(() -> userService.changePassword(user.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.INVALID_CREDENTIALS.getMessage()); + } + + @Test + @DisplayName("새 비밀번호 정책 위반 → INVALID_PASSWORD 예외") + void changePassword_invalidNewPassword() { + // given + User user = User.createUser("invalidpw", "invalidpw@example.com", passwordEncoder.encode("Valid1!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + // 숫자/특수문자 없는 비밀번호 + ChangePasswordRequest request = new ChangePasswordRequest("Valid1!", "short"); + + // when & then + assertThatThrownBy(() -> userService.changePassword(user.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.INVALID_PASSWORD.getMessage()); + } + + @Test + @DisplayName("탈퇴한 유저 비밀번호 변경 → USER_DELETED 예외") + void changePassword_deletedUser() { + // given + User user = User.createUser("deletedpw", "deletedpw@example.com", passwordEncoder.encode("Valid1!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.DELETED); + userRepository.save(user); + + ChangePasswordRequest request = new ChangePasswordRequest("Valid1!", "NewP@ssw0rd!"); + + // when & then + assertThatThrownBy(() -> userService.changePassword(user.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_DELETED.getMessage()); + } + + @Test + @DisplayName("정지된 유저 비밀번호 변경 → USER_SUSPENDED 예외") + void changePassword_suspendedUser() { + // given + User user = User.createUser("suspendedpw", "suspendedpw@example.com", passwordEncoder.encode("Valid1!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.SUSPENDED); + userRepository.save(user); + + ChangePasswordRequest request = new ChangePasswordRequest("Valid1!", "NewP@ssw0rd!"); + + // when & then + assertThatThrownBy(() -> userService.changePassword(user.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_SUSPENDED.getMessage()); + } + + @Test + @DisplayName("존재하지 않는 유저 비밀번호 변경 → USER_NOT_FOUND 예외") + void changePassword_userNotFound() { + // when & then + ChangePasswordRequest request = new ChangePasswordRequest("dummy", "NewP@ssw0rd!"); + assertThatThrownBy(() -> userService.changePassword(999L, request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_NOT_FOUND.getMessage()); + } + // ====================== 사용자 탈퇴 테스트 ====================== @Test From 85e3d1db43615af9b8c5a502bcb364cdd9a36b66 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Mon, 29 Sep 2025 10:53:13 +0900 Subject: [PATCH 4/4] =?UTF-8?q?Feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20API=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserControllerDocs.java | 15 +++++++++++++ .../back/domain/user/service/UserService.java | 5 +++++ .../com/back/global/exception/ErrorCode.java | 1 + .../user/controller/UserControllerTest.java | 22 +++++++++++++++++++ .../domain/user/service/UserServiceTest.java | 17 ++++++++++++++ 5 files changed, 60 insertions(+) diff --git a/src/main/java/com/back/domain/user/controller/UserControllerDocs.java b/src/main/java/com/back/domain/user/controller/UserControllerDocs.java index 7a79c679..a282d385 100644 --- a/src/main/java/com/back/domain/user/controller/UserControllerDocs.java +++ b/src/main/java/com/back/domain/user/controller/UserControllerDocs.java @@ -341,6 +341,21 @@ ResponseEntity> updateMyProfile( """) ) ), + @ApiResponse( + responseCode = "403", + description = "소셜 로그인 회원 비밀번호 변경 불가", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_010", + "message": "소셜 로그인 회원은 비밀번호를 변경할 수 없습니다.", + "data": null + } + """) + ) + ), @ApiResponse( responseCode = "403", description = "정지된 계정", diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index 76311dce..8568655c 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -78,6 +78,11 @@ public void changePassword(Long userId, ChangePasswordRequest request) { // 사용자 조회 및 상태 검증 User user = getValidUser(userId); + // 소셜 로그인 사용자는 비밀번호 변경 불가 + if (user.getProvider() != null) { + throw new CustomException(ErrorCode.SOCIAL_PASSWORD_CHANGE_FORBIDDEN); + } + // 현재 비밀번호 검증 if (!passwordEncoder.matches(request.currentPassword(), user.getPassword())) { throw new CustomException(ErrorCode.INVALID_CREDENTIALS); diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index b7520ce7..0d34f555 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -18,6 +18,7 @@ public enum ErrorCode { USER_EMAIL_NOT_VERIFIED(HttpStatus.FORBIDDEN, "USER_007", "이메일 인증 후 로그인할 수 있습니다."), USER_SUSPENDED(HttpStatus.FORBIDDEN, "USER_008", "정지된 계정입니다. 관리자에게 문의하세요."), USER_DELETED(HttpStatus.GONE, "USER_009", "탈퇴한 계정입니다."), + SOCIAL_PASSWORD_CHANGE_FORBIDDEN(HttpStatus.FORBIDDEN, "USER_010", "소셜 로그인 회원은 비밀번호를 변경할 수 없습니다."), // ======================== 스터디룸 관련 ======================== ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "ROOM_001", "존재하지 않는 방입니다."), diff --git a/src/test/java/com/back/domain/user/controller/UserControllerTest.java b/src/test/java/com/back/domain/user/controller/UserControllerTest.java index a5da7c4c..0fc67bfb 100644 --- a/src/test/java/com/back/domain/user/controller/UserControllerTest.java +++ b/src/test/java/com/back/domain/user/controller/UserControllerTest.java @@ -413,6 +413,28 @@ void changePassword_invalidNewPassword() throws Exception { .andExpect(jsonPath("$.message").value("비밀번호는 최소 8자 이상, 숫자/특수문자를 포함해야 합니다.")); } + @Test + @DisplayName("소셜 로그인 회원 비밀번호 변경 시도 → 403 Forbidden (USER_010)") + void changePassword_socialUser() throws Exception { + User user = User.createUser("socialuser", "social@example.com", null); + user.setProvider("kakao"); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + ChangePasswordRequest request = new ChangePasswordRequest("dummy", "NewP@ssw0rd!"); + + mvc.perform(patch("/api/users/me/password") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("USER_010")) + .andExpect(jsonPath("$.message").value("소셜 로그인 회원은 비밀번호를 변경할 수 없습니다.")); + } + @Test @DisplayName("탈퇴 계정 비밀번호 변경 시도 → 410 Gone (USER_009)") void changePassword_deletedUser() throws Exception { diff --git a/src/test/java/com/back/domain/user/service/UserServiceTest.java b/src/test/java/com/back/domain/user/service/UserServiceTest.java index 2745d280..a4ead658 100644 --- a/src/test/java/com/back/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/back/domain/user/service/UserServiceTest.java @@ -236,6 +236,23 @@ void changePassword_invalidNewPassword() { .hasMessage(ErrorCode.INVALID_PASSWORD.getMessage()); } + @Test + @DisplayName("소셜 로그인 회원 비밀번호 변경 시도 → SOCIAL_PASSWORD_CHANGE_FORBIDDEN 예외") + void changePassword_socialUser() { + // given + User user = User.createUser("socialuser", "social@example.com", null); + user.setProvider("kakao"); // 소셜 로그인 회원 + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + ChangePasswordRequest request = new ChangePasswordRequest("dummy", "NewP@ssw0rd!"); + + // when & then + assertThatThrownBy(() -> userService.changePassword(user.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.SOCIAL_PASSWORD_CHANGE_FORBIDDEN.getMessage()); + } + @Test @DisplayName("탈퇴한 유저 비밀번호 변경 → USER_DELETED 예외") void changePassword_deletedUser() {