diff --git a/src/main/java/com/daytodo/domain/user/controller/UserController.java b/src/main/java/com/daytodo/domain/user/controller/UserController.java index 364a6f9..212a453 100644 --- a/src/main/java/com/daytodo/domain/user/controller/UserController.java +++ b/src/main/java/com/daytodo/domain/user/controller/UserController.java @@ -55,6 +55,16 @@ public UserResponse.Profile updateProfile( return profileService.updateProfile(userId, nickname, profileImage); } + @Operation(summary = "비밀번호 변경 (마이페이지)") + @PatchMapping("/password") + public ResponseEntity changePassword( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody UserRequest.ChangePassword request + ) { + userService.changePassword(userId, request); + return ResponseEntity.noContent().build(); + } + @Operation(summary = "관심지역 조회") @GetMapping("/interest-region") public UserResponse.InterestRegions getInterestRegions( @@ -133,4 +143,4 @@ public ResponseEntity submitFeedback( public UserResponse.Policies getPolicies() { return policyService.getPolicies(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/daytodo/domain/user/dto/UserRequest.java b/src/main/java/com/daytodo/domain/user/dto/UserRequest.java index 2117a1c..3efb962 100644 --- a/src/main/java/com/daytodo/domain/user/dto/UserRequest.java +++ b/src/main/java/com/daytodo/domain/user/dto/UserRequest.java @@ -3,6 +3,7 @@ import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import com.daytodo.domain.user.enums.DevicePlatform; @@ -46,4 +47,19 @@ public record SubmitFeedback( String content ) { } -} + + public record ChangePassword( + @NotBlank(message = "현재 비밀번호는 필수입니다.") + @Size(max = 72, message = "비밀번호는 72자 이하여야 합니다.") + String currentPassword, + + @NotBlank(message = "새 비밀번호는 필수입니다.") + @Size(max = 72, message = "비밀번호는 72자 이하여야 합니다.") + @Pattern( + regexp = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d!@#$%^&*()_+]{8,}$", + message = "비밀번호는 영문+숫자 조합 8자 이상이어야 합니다." + ) + String newPassword + ) { + } +} \ No newline at end of file diff --git a/src/main/java/com/daytodo/domain/user/exception/code/UserErrorCode.java b/src/main/java/com/daytodo/domain/user/exception/code/UserErrorCode.java index 8856aca..ea61a96 100644 --- a/src/main/java/com/daytodo/domain/user/exception/code/UserErrorCode.java +++ b/src/main/java/com/daytodo/domain/user/exception/code/UserErrorCode.java @@ -17,9 +17,11 @@ public enum UserErrorCode implements BaseErrorCode { PROFILE_IMAGE_STORAGE_NOT_CONFIGURED(HttpStatus.SERVICE_UNAVAILABLE, "PROFILE_IMAGE_STORAGE_NOT_CONFIGURED", "프로필 이미지 저장소 설정이 완료되지 않았습니다."), PROFILE_IMAGE_UPLOAD_FAILED(HttpStatus.BAD_GATEWAY, "PROFILE_IMAGE_UPLOAD_FAILED", "프로필 이미지를 저장하지 못했습니다."), FEEDBACK_TOO_SHORT(HttpStatus.BAD_REQUEST, "FEEDBACK_TOO_SHORT", "의견은 공백을 제거한 후 100자 이상이어야 합니다."), - POLICY_NOT_AVAILABLE(HttpStatus.INTERNAL_SERVER_ERROR, "POLICY_NOT_AVAILABLE", "약관 및 정책을 불러오지 못했습니다."); + POLICY_NOT_AVAILABLE(HttpStatus.INTERNAL_SERVER_ERROR, "POLICY_NOT_AVAILABLE", "약관 및 정책을 불러오지 못했습니다."), + INVALID_CURRENT_PASSWORD(HttpStatus.UNAUTHORIZED, "INVALID_CURRENT_PASSWORD", "현재 비밀번호가 일치하지 않습니다."), + SOCIAL_ACCOUNT_PASSWORD_CHANGE_NOT_ALLOWED(HttpStatus.CONFLICT, "SOCIAL_ACCOUNT_PASSWORD_CHANGE_NOT_ALLOWED", "소셜 로그인 계정은 비밀번호 변경을 지원하지 않습니다."); private final HttpStatus status; private final String code; private final String message; -} +} \ No newline at end of file diff --git a/src/main/java/com/daytodo/domain/user/service/UserService.java b/src/main/java/com/daytodo/domain/user/service/UserService.java index 5cd3d81..0510c3f 100644 --- a/src/main/java/com/daytodo/domain/user/service/UserService.java +++ b/src/main/java/com/daytodo/domain/user/service/UserService.java @@ -1,5 +1,7 @@ package com.daytodo.domain.user.service; +import com.daytodo.domain.auth.repository.PasswordResetTokenRepository; +import com.daytodo.domain.auth.repository.RefreshTokenRepository; import com.daytodo.domain.region.entity.Region; import com.daytodo.domain.region.exception.code.RegionErrorCode; import com.daytodo.domain.region.repository.RegionRepository; @@ -7,12 +9,14 @@ import com.daytodo.domain.user.dto.UserResponse; import com.daytodo.domain.user.entity.User; import com.daytodo.domain.user.entity.mapping.UserInterestRegion; +import com.daytodo.domain.user.enums.LoginType; import com.daytodo.domain.user.enums.UserStatus; import com.daytodo.domain.user.exception.code.UserErrorCode; import com.daytodo.domain.user.repository.UserInterestRegionRepository; import com.daytodo.domain.user.repository.UserRepository; import com.daytodo.global.apiPayload.exception.ProjectException; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,6 +34,9 @@ public class UserService { private final UserRepository userRepository; private final UserInterestRegionRepository interestRegionRepository; private final RegionRepository regionRepository; + private final PasswordEncoder passwordEncoder; + private final RefreshTokenRepository refreshTokenRepository; + private final PasswordResetTokenRepository passwordResetTokenRepository; private final Clock clock; public UserResponse.Profile getProfile(Long userId) { @@ -85,6 +92,24 @@ public void withdraw(Long userId) { user.withdraw(LocalDateTime.now(clock)); } + @Transactional + public void changePassword(Long userId, UserRequest.ChangePassword request) { + User user = userRepository.findActiveUserForUpdate(userId, UserStatus.ACTIVE) + .orElseThrow(() -> new ProjectException(UserErrorCode.USER_NOT_FOUND)); + // 네이버 전용 계정(password=null)은 현재 비밀번호가 없으므로 마이페이지에서 변경할 수 없다. + if (user.getLoginType() != LoginType.LOCAL) { + throw new ProjectException(UserErrorCode.SOCIAL_ACCOUNT_PASSWORD_CHANGE_NOT_ALLOWED); + } + if (!passwordEncoder.matches(request.currentPassword(), user.getPassword())) { + throw new ProjectException(UserErrorCode.INVALID_CURRENT_PASSWORD); + } + user.changePassword(passwordEncoder.encode(request.newPassword())); + // 비밀번호 변경 시 기존에 발급된 refresh token(탈취 가능성 있는 세션)과 + // 대기 중이던 비밀번호 재설정 코드를 함께 무효화한다. + refreshTokenRepository.findById(userId).ifPresent(refreshTokenRepository::delete); + passwordResetTokenRepository.findById(userId).ifPresent(passwordResetTokenRepository::delete); + } + public User getActiveUser(Long userId) { return userRepository.findByIdAndUserStatus(userId, UserStatus.ACTIVE) .orElseThrow(() -> new ProjectException(UserErrorCode.USER_NOT_FOUND)); @@ -113,4 +138,4 @@ private UserResponse.InterestRegion toInterestRegion(Region region) { region.getParent() == null ? null : region.getParent().getRegionName() ); } -} +} \ No newline at end of file diff --git a/src/main/java/com/daytodo/global/config/SecurityConfig.java b/src/main/java/com/daytodo/global/config/SecurityConfig.java index 44d0345..5808318 100644 --- a/src/main/java/com/daytodo/global/config/SecurityConfig.java +++ b/src/main/java/com/daytodo/global/config/SecurityConfig.java @@ -96,7 +96,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(HttpMethod.PATCH, "/users/profile", "/users/interest-regions", - "/users/notifications" + "/users/notifications", + "/users/password" ).authenticated() .requestMatchers(HttpMethod.DELETE, "/users/me").authenticated() .requestMatchers(HttpMethod.DELETE, "/users/fcm-token").authenticated() @@ -154,4 +155,4 @@ private void writeSecurityError( response.setCharacterEncoding(java.nio.charset.StandardCharsets.UTF_8.name()); objectMapper.writeValue(response.getWriter(), ErrorResponse.of(errorCode)); } -} +} \ No newline at end of file diff --git a/src/test/java/com/daytodo/domain/user/controller/UserControllerTest.java b/src/test/java/com/daytodo/domain/user/controller/UserControllerTest.java index fc2c9d6..f98baa3 100644 --- a/src/test/java/com/daytodo/domain/user/controller/UserControllerTest.java +++ b/src/test/java/com/daytodo/domain/user/controller/UserControllerTest.java @@ -20,6 +20,7 @@ import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -38,13 +39,13 @@ class UserControllerTest { @BeforeEach void setUp() { mockMvc = MockMvcBuilders.standaloneSetup(new UserController( - userService, - profileService, - notificationService, - feedbackService, - policyService, - fcmTokenService - )) + userService, + profileService, + notificationService, + feedbackService, + policyService, + fcmTokenService + )) .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) .build(); } @@ -84,6 +85,23 @@ void registersFcmTokenUsingAuthenticatedPrincipal() throws Exception { ); } + @Test + void changesPasswordUsingAuthenticatedPrincipal() throws Exception { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(1L, null) + ); + + mockMvc.perform(patch("/users/password") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"currentPassword\":\"current1234\",\"newPassword\":\"newPassword1234\"}")) + .andExpect(status().isNoContent()); + + verify(userService).changePassword( + 1L, + new com.daytodo.domain.user.dto.UserRequest.ChangePassword("current1234", "newPassword1234") + ); + } + @Test void deletesFcmTokenUsingAuthenticatedPrincipal() throws Exception { SecurityContextHolder.getContext().setAuthentication( @@ -100,4 +118,4 @@ void deletesFcmTokenUsingAuthenticatedPrincipal() throws Exception { new com.daytodo.domain.user.dto.UserRequest.DeleteFcmToken("device-token") ); } -} +} \ No newline at end of file diff --git a/src/test/java/com/daytodo/domain/user/service/UserServiceTest.java b/src/test/java/com/daytodo/domain/user/service/UserServiceTest.java index 5a20d66..e78e4e8 100644 --- a/src/test/java/com/daytodo/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/daytodo/domain/user/service/UserServiceTest.java @@ -1,5 +1,9 @@ package com.daytodo.domain.user.service; +import com.daytodo.domain.auth.entity.PasswordResetToken; +import com.daytodo.domain.auth.entity.RefreshToken; +import com.daytodo.domain.auth.repository.PasswordResetTokenRepository; +import com.daytodo.domain.auth.repository.RefreshTokenRepository; import com.daytodo.domain.region.entity.Region; import com.daytodo.domain.region.enums.RegionLevel; import com.daytodo.domain.region.repository.RegionRepository; @@ -18,6 +22,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.util.ReflectionTestUtils; import java.time.Clock; @@ -43,12 +48,18 @@ class UserServiceTest { @Mock UserRepository userRepository; @Mock UserInterestRegionRepository interestRegionRepository; @Mock RegionRepository regionRepository; + @Mock PasswordEncoder passwordEncoder; + @Mock RefreshTokenRepository refreshTokenRepository; + @Mock PasswordResetTokenRepository passwordResetTokenRepository; UserService userService; @BeforeEach void setUp() { - userService = new UserService(userRepository, interestRegionRepository, regionRepository, CLOCK); + userService = new UserService( + userRepository, interestRegionRepository, regionRepository, passwordEncoder, + refreshTokenRepository, passwordResetTokenRepository, CLOCK + ); } @Test @@ -143,6 +154,64 @@ void rejectsRepeatedWithdrawal() { .isEqualTo(UserErrorCode.USER_ALREADY_WITHDRAWN); } + @Test + void changesPasswordWhenCurrentPasswordMatches() { + User user = user(1L, UserStatus.ACTIVE); + RefreshToken refreshToken = new RefreshToken(1L, "refresh-token", LocalDateTime.now(CLOCK).plusDays(1)); + PasswordResetToken resetToken = new PasswordResetToken(1L, "123456", LocalDateTime.now(CLOCK).plusMinutes(10)); + when(userRepository.findActiveUserForUpdate(1L, UserStatus.ACTIVE)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("current1234", "password")).thenReturn(true); + when(passwordEncoder.encode("newPassword1234")).thenReturn("encodedNewPassword"); + when(refreshTokenRepository.findById(1L)).thenReturn(Optional.of(refreshToken)); + when(passwordResetTokenRepository.findById(1L)).thenReturn(Optional.of(resetToken)); + + userService.changePassword(1L, new UserRequest.ChangePassword("current1234", "newPassword1234")); + + assertThat(user.getPassword()).isEqualTo("encodedNewPassword"); + verify(refreshTokenRepository).delete(refreshToken); + verify(passwordResetTokenRepository).delete(resetToken); + } + + @Test + void rejectsPasswordChangeWhenCurrentPasswordDoesNotMatch() { + User user = user(1L, UserStatus.ACTIVE); + when(userRepository.findActiveUserForUpdate(1L, UserStatus.ACTIVE)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("wrongPassword", "password")).thenReturn(false); + + assertThatThrownBy(() -> userService.changePassword( + 1L, new UserRequest.ChangePassword("wrongPassword", "newPassword1234") + )) + .isInstanceOf(ProjectException.class) + .extracting("errorCode") + .isEqualTo(UserErrorCode.INVALID_CURRENT_PASSWORD); + } + + @Test + void rejectsPasswordChangeForSocialOnlyAccount() { + User user = new User("user@example.com", null, "daytodo", null, LoginType.NAVER); + ReflectionTestUtils.setField(user, "id", 1L); + when(userRepository.findActiveUserForUpdate(1L, UserStatus.ACTIVE)).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> userService.changePassword( + 1L, new UserRequest.ChangePassword("current1234", "newPassword1234") + )) + .isInstanceOf(ProjectException.class) + .extracting("errorCode") + .isEqualTo(UserErrorCode.SOCIAL_ACCOUNT_PASSWORD_CHANGE_NOT_ALLOWED); + } + + @Test + void rejectsPasswordChangeWhenUserNotFound() { + when(userRepository.findActiveUserForUpdate(1L, UserStatus.ACTIVE)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.changePassword( + 1L, new UserRequest.ChangePassword("current1234", "newPassword1234") + )) + .isInstanceOf(ProjectException.class) + .extracting("errorCode") + .isEqualTo(UserErrorCode.USER_NOT_FOUND); + } + private User user(Long id, UserStatus status) { User user = new User("user@example.com", "password", "daytodo", "profile.png", LoginType.LOCAL); ReflectionTestUtils.setField(user, "id", id); @@ -155,4 +224,4 @@ private Region region(Long id, Region parent, String name, RegionLevel level) { ReflectionTestUtils.setField(region, "regionId", id); return region; } -} +} \ No newline at end of file