diff --git a/apps/user-service/src/main/java/site/icebang/domain/auth/controller/AuthController.java b/apps/user-service/src/main/java/site/icebang/domain/auth/controller/AuthController.java index 2303cf74..b78cc2cb 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/auth/controller/AuthController.java +++ b/apps/user-service/src/main/java/site/icebang/domain/auth/controller/AuthController.java @@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor; import site.icebang.common.dto.ApiResponse; +import site.icebang.domain.auth.dto.ChangePasswordRequestDto; import site.icebang.domain.auth.dto.LoginRequestDto; import site.icebang.domain.auth.dto.RegisterDto; import site.icebang.domain.auth.model.AuthCredential; @@ -75,4 +76,13 @@ public ApiResponse logout(HttpServletRequest request) { return ApiResponse.success(null); } + + @PatchMapping("/change-password") + public ApiResponse changePassword( + @Valid @RequestBody ChangePasswordRequestDto request, + @AuthenticationPrincipal AuthCredential user) { + + authService.changePassword(user.getEmail(), request); + return ApiResponse.success(null); + } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/auth/dto/ChangePasswordRequestDto.java b/apps/user-service/src/main/java/site/icebang/domain/auth/dto/ChangePasswordRequestDto.java new file mode 100644 index 00000000..aed29e7c --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/auth/dto/ChangePasswordRequestDto.java @@ -0,0 +1,20 @@ +package site.icebang.domain.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ChangePasswordRequestDto { + @NotBlank(message = "현재 비밀번호는 필수입니다") + private String currentPassword; + + @NotBlank(message = "새 비밀번호는 필수입니다") + @Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다") + private String newPassword; + + @NotBlank(message = "새 비밀번호 확인은 필수입니다") + private String confirmPassword; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/auth/mapper/AuthMapper.java b/apps/user-service/src/main/java/site/icebang/domain/auth/mapper/AuthMapper.java index ddc07ffe..ef2afca9 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/auth/mapper/AuthMapper.java +++ b/apps/user-service/src/main/java/site/icebang/domain/auth/mapper/AuthMapper.java @@ -16,4 +16,8 @@ public interface AuthMapper { int insertUserOrganization(RegisterDto dto); // user_organizations insert int insertUserRoles(RegisterDto dto); // user_roles insert (foreach) + + String findPasswordByEmail(String email); + + int updatePassword(String email, String newPassword); } diff --git a/apps/user-service/src/main/java/site/icebang/domain/auth/service/AuthService.java b/apps/user-service/src/main/java/site/icebang/domain/auth/service/AuthService.java index 25a5bd42..4a9d7a23 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/auth/service/AuthService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/auth/service/AuthService.java @@ -8,10 +8,13 @@ import site.icebang.common.exception.DuplicateDataException; import site.icebang.common.utils.RandomPasswordGenerator; +import site.icebang.domain.auth.dto.ChangePasswordRequestDto; import site.icebang.domain.auth.dto.RegisterDto; import site.icebang.domain.auth.mapper.AuthMapper; import site.icebang.domain.email.dto.EmailRequest; import site.icebang.domain.email.service.EmailService; +import site.icebang.global.handler.exception.InvalidPasswordException; +import site.icebang.global.handler.exception.PasswordMismatchException; @Service @RequiredArgsConstructor @@ -51,4 +54,26 @@ public void registerUser(RegisterDto registerDto) { emailService.send(emailRequest); } + + public void changePassword(String email, ChangePasswordRequestDto request) { + // 1. 새 비밀번호와 확인 비밀번호 일치 검증 + if (!request.getNewPassword().equals(request.getConfirmPassword())) { + throw new PasswordMismatchException("새 비밀번호가 일치하지 않습니다"); + } + + // 2. 사용자 조회 + String currentHashedPassword = authMapper.findPasswordByEmail(email); + if (currentHashedPassword == null) { + throw new IllegalArgumentException("사용자를 찾을 수 없습니다"); // 이건 그대로 + } + + // 3. 현재 비밀번호 검증 + if (!passwordEncoder.matches(request.getCurrentPassword(), currentHashedPassword)) { + throw new InvalidPasswordException("현재 비밀번호가 올바르지 않습니다"); + } + + // 4. 새 비밀번호 해싱 및 업데이트 + String hashedNewPassword = passwordEncoder.encode(request.getNewPassword()); + authMapper.updatePassword(email, hashedNewPassword); + } } diff --git a/apps/user-service/src/main/java/site/icebang/global/config/Mail/MailConfig.java b/apps/user-service/src/main/java/site/icebang/global/config/Mail/MailConfig.java new file mode 100644 index 00000000..86fc8cd0 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/global/config/Mail/MailConfig.java @@ -0,0 +1,29 @@ +package site.icebang.global.config.Mail; + +import java.util.Properties; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +@Configuration +public class MailConfig { + + @Bean + public JavaMailSender javaMailSender() { + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost("smtp.gmail.com"); + mailSender.setPort(587); + mailSender.setUsername(""); + mailSender.setPassword(""); + + Properties props = mailSender.getJavaMailProperties(); + props.put("mail.transport.protocol", "smtp"); + props.put("mail.smtp.auth", "true"); + props.put("mail.smtp.starttls.enable", "true"); + props.put("mail.debug", "true"); + + return mailSender; + } +} diff --git a/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java b/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java index 485e7e1e..dde08404 100644 --- a/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java +++ b/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java @@ -118,4 +118,10 @@ public ApiResponse handleDuplicateData(DuplicateDataException ex) { log.warn(ex.getMessage(), ex); return ApiResponse.error("Duplicate: " + ex.getMessage(), HttpStatus.CONFLICT); } + + @ExceptionHandler({PasswordMismatchException.class, InvalidPasswordException.class}) // 추가 + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handlePasswordException(RuntimeException ex) { + return ApiResponse.error(ex.getMessage(), HttpStatus.BAD_REQUEST); + } } diff --git a/apps/user-service/src/main/java/site/icebang/global/handler/exception/InvalidPasswordException.java b/apps/user-service/src/main/java/site/icebang/global/handler/exception/InvalidPasswordException.java new file mode 100644 index 00000000..261ba86a --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/global/handler/exception/InvalidPasswordException.java @@ -0,0 +1,8 @@ +package site.icebang.global.handler.exception; + +public class InvalidPasswordException extends RuntimeException { + + public InvalidPasswordException(String message) { + super(message); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/global/handler/exception/PasswordMismatchException.java b/apps/user-service/src/main/java/site/icebang/global/handler/exception/PasswordMismatchException.java new file mode 100644 index 00000000..bb39daf4 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/global/handler/exception/PasswordMismatchException.java @@ -0,0 +1,7 @@ +package site.icebang.global.handler.exception; + +public class PasswordMismatchException extends RuntimeException { + public PasswordMismatchException(String message) { + super(message); + } +} diff --git a/apps/user-service/src/main/resources/mybatis/mapper/AuthMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/AuthMapper.xml index d98c7299..60dcf956 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/AuthMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/AuthMapper.xml @@ -48,4 +48,16 @@ + + + + UPDATE user + SET password = #{newPassword} + WHERE email = #{email} + + \ No newline at end of file diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/auth/AuthApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/auth/AuthApiIntegrationTest.java index 276ce7c8..f1a4a748 100644 --- a/apps/user-service/src/test/java/site/icebang/integration/tests/auth/AuthApiIntegrationTest.java +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/auth/AuthApiIntegrationTest.java @@ -3,6 +3,7 @@ import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; import static com.epages.restdocs.apispec.ResourceDocumentation.*; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; import static org.springframework.restdocs.payload.PayloadDocumentation.*; @@ -611,4 +612,25 @@ void getPermissions_unauthenticated_returns_unauthorized() throws Exception { .description("HTTP 상태 (UNAUTHORIZED)")) .build()))); } + + @Test + @DisplayName("비밀번호 변경 실패 - 미인증 사용자") + void changePassword_fail_unauthorized() throws Exception { + // given - 로그인하지 않은 상태 + Map changePasswordRequest = new HashMap<>(); + changePasswordRequest.put("currentPassword", "qwer1234!A"); + changePasswordRequest.put("newPassword", "newPassword123!A"); + changePasswordRequest.put("confirmPassword", "newPassword123!A"); + + // when & then + mockMvc + .perform( + patch(getApiUrlForDocs("/v0/auth/change-password")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(changePasswordRequest))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value("UNAUTHORIZED")) + .andExpect(jsonPath("$.data").doesNotExist()); + } }