Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ public UserResponse.Profile updateProfile(
return profileService.updateProfile(userId, nickname, profileImage);
}

@Operation(summary = "비밀번호 변경 (마이페이지)")
@PatchMapping("/password")
public ResponseEntity<Void> 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(
Expand Down Expand Up @@ -133,4 +143,4 @@ public ResponseEntity<Void> submitFeedback(
public UserResponse.Policies getPolicies() {
return policyService.getPolicies();
}
}
}
16 changes: 15 additions & 1 deletion src/main/java/com/daytodo/domain/user/dto/UserRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -46,4 +47,17 @@ public record SubmitFeedback(
String content
) {
}
}

public record ChangePassword(
@NotBlank(message = "현재 비밀번호는 필수입니다.")
String currentPassword,

@NotBlank(message = "새 비밀번호는 필수입니다.")
@Pattern(
regexp = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d!@#$%^&*()_+]{8,}$",
message = "비밀번호는 영문+숫자 조합 8자 이상이어야 합니다."
)
String newPassword
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
18 changes: 17 additions & 1 deletion src/main/java/com/daytodo/domain/user/service/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,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;

Expand All @@ -30,6 +32,7 @@ public class UserService {
private final UserRepository userRepository;
private final UserInterestRegionRepository interestRegionRepository;
private final RegionRepository regionRepository;
private final PasswordEncoder passwordEncoder;
private final Clock clock;

public UserResponse.Profile getProfile(Long userId) {
Expand Down Expand Up @@ -85,6 +88,19 @@ public void withdraw(Long userId) {
user.withdraw(LocalDateTime.now(clock));
}

@Transactional
public void changePassword(Long userId, UserRequest.ChangePassword request) {
User user = getActiveUser(userId);
// 네이버 전용 계정(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()));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

public User getActiveUser(Long userId) {
return userRepository.findByIdAndUserStatus(userId, UserStatus.ACTIVE)
.orElseThrow(() -> new ProjectException(UserErrorCode.USER_NOT_FOUND));
Expand Down Expand Up @@ -113,4 +129,4 @@ private UserResponse.InterestRegion toInterestRegion(Region region) {
region.getParent() == null ? null : region.getParent().getRegionName()
);
}
}
}
5 changes: 3 additions & 2 deletions src/main/java/com/daytodo/global/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -154,4 +155,4 @@ private void writeSecurityError(
response.setCharacterEncoding(java.nio.charset.StandardCharsets.UTF_8.name());
objectMapper.writeValue(response.getWriter(), ErrorResponse.of(errorCode));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
}
Expand Down Expand Up @@ -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(
Expand All @@ -100,4 +118,4 @@ void deletesFcmTokenUsingAuthenticatedPrincipal() throws Exception {
new com.daytodo.domain.user.dto.UserRequest.DeleteFcmToken("device-token")
);
}
}
}
48 changes: 46 additions & 2 deletions src/test/java/com/daytodo/domain/user/service/UserServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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;
Expand All @@ -43,12 +44,15 @@ class UserServiceTest {
@Mock UserRepository userRepository;
@Mock UserInterestRegionRepository interestRegionRepository;
@Mock RegionRepository regionRepository;
@Mock PasswordEncoder passwordEncoder;

UserService userService;

@BeforeEach
void setUp() {
userService = new UserService(userRepository, interestRegionRepository, regionRepository, CLOCK);
userService = new UserService(
userRepository, interestRegionRepository, regionRepository, passwordEncoder, CLOCK
);
}

@Test
Expand Down Expand Up @@ -143,6 +147,46 @@ void rejectsRepeatedWithdrawal() {
.isEqualTo(UserErrorCode.USER_ALREADY_WITHDRAWN);
}

@Test
void changesPasswordWhenCurrentPasswordMatches() {
User user = user(1L, UserStatus.ACTIVE);
when(userRepository.findByIdAndUserStatus(1L, UserStatus.ACTIVE)).thenReturn(Optional.of(user));
when(passwordEncoder.matches("current1234", "password")).thenReturn(true);
when(passwordEncoder.encode("newPassword1234")).thenReturn("encodedNewPassword");

userService.changePassword(1L, new UserRequest.ChangePassword("current1234", "newPassword1234"));

assertThat(user.getPassword()).isEqualTo("encodedNewPassword");
}

@Test
void rejectsPasswordChangeWhenCurrentPasswordDoesNotMatch() {
User user = user(1L, UserStatus.ACTIVE);
when(userRepository.findByIdAndUserStatus(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.findByIdAndUserStatus(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);
}

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);
Expand All @@ -155,4 +199,4 @@ private Region region(Long id, Region parent, String name, RegionLevel level) {
ReflectionTestUtils.setField(region, "regionId", id);
return region;
}
}
}