Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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 @@ -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;
Expand Down Expand Up @@ -75,4 +76,13 @@ public ApiResponse<Void> logout(HttpServletRequest request) {

return ApiResponse.success(null);
}

@PatchMapping("/change-password")
public ApiResponse<Void> changePassword(
@Valid @RequestBody ChangePasswordRequestDto request,
@AuthenticationPrincipal AuthCredential user) {

authService.changePassword(user.getEmail(), request);
return ApiResponse.success(null);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +20 to +22
Copy link
Collaborator

@can019 can019 Sep 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pk(BigInteger)로 찾는게 더 빨라 보일 것 같습니다.

Controller에서

void method(@AuthenticationPrincipal AuthCredential user) {

    BigInteger id = user.getId();
}

이렇게 꺼낼 수 있습니다

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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("새 비밀번호가 일치하지 않습니다");
}

Comment on lines +58 to +63
Copy link
Collaborator

@can019 can019 Sep 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분은 비즈니스 로직이 아니라 단순한 입력값 검증(validation) 으로 보는 게 맞습니다.
따라서 아래 세 가지 방향 중 하나로 하면 좋을 것 같습니다.

  1. MethodArgumentNotValidException를 상속한 커스텀 예외 사용

    • 기존 Bean Validation 흐름을 그대로 활용하면서도, 비밀번호 검증 전용 예외를 구분할 수 있습니다.
  2. 커스텀 @Validate 어노테이션 작성 후 @Valid에게 완전 위임

    • 컨트롤러 단에서 DTO에 어노테이션을 붙이는 것만으로 검증이 가능합니다.
  3. 검증 로직을 Controller 단으로 이동

    • 서비스 레이어에서 검증을 제거하고, 요청 파라미터 검증 책임을 명확히 컨트롤러에 둡니다.

PasswordMismatch라는 이름은 “DB에 저장된 비밀번호와 입력 비밀번호가 일치하지 않는 경우”를 연상시키므로,
단순히 새 비밀번호와 새 비밀번호 확인값이 다른 케이스를 표현하기에는 좋지 않다고 생각됩니다.

GPT는 DB에 저장된 비밀번호와 입력 비밀번호가 일치하지 않는 경우로 알아들어 비지니스 로직인데 왜 MethodArgumentNotValidException를 발생시키냐고 한 것 같습니다.

또한 새 비밀번호와 새 비밀번호 확인은 실제 서비스에서도 프론트엔드에서만 체크하고

백엔드에서 별도 검증을 생략하는 경우가 흔합니다.
이 점도 고려해, 백엔드에서 검증을 유지할지 여부부터 명확히 결정하는 게 좋을 것 같습니다.

비즈니스 로직이란 컴퓨터 프로그램이 현실 세계의 비즈니스 규칙에 따라 데이터를 생성, 표시, 저장, 변경하는 핵심 로직입니다. by Google

// 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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +10 to +28
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희는 JavaMailSender Bean 설정을 application.yml에서 관리하고 있습니다.
아마 테스트 환경 때문에 Bean을 직접 등록하신 것으로 보입니다.

따라서 다음 두 가지 중 하나로 맞춰 주시면 좋겠습니다.

  1. 각 환경별 application-*.yml에 동일한 설정 정의

    • 테스트, 스테이징, 프로덕션 등 모든 프로필에 동일하게 mail 설정을 작성합니다.
  2. 프로덕션 환경도 Bean 등록 방식으로 통일

    • 테스트처럼 @Configuration 클래스에서 JavaMailSender Bean을 생성하도록 변경합니다.

두 방식 중 어떤 것을 선택하든,
환경별 설정 관리가 일관되도록 해 주시면 유지보수가 훨씬 수월해집니다.

}
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,10 @@ public ApiResponse<String> 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<String> handlePasswordException(RuntimeException ex) {
return ApiResponse.error(ex.getMessage(), HttpStatus.BAD_REQUEST);
}
Comment on lines +121 to +126
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

각 도메인에서 400 응답을 내려야 하는 경우마다 CustomException을 새로 생성하고,
그때마다 GlobalExceptionHandler에 일일이 등록하게 되면
핸들러 클래스는 금세 100줄, 200줄, 300줄로 커질 위험이 있습니다.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package site.icebang.global.handler.exception;

public class InvalidPasswordException extends RuntimeException {

public InvalidPasswordException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package site.icebang.global.handler.exception;

public class PasswordMismatchException extends RuntimeException {
public PasswordMismatchException(String message) {
super(message);
}
}
12 changes: 12 additions & 0 deletions apps/user-service/src/main/resources/mybatis/mapper/AuthMapper.xml
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,16 @@
</foreach>
</insert>

<select id="findPasswordByEmail" parameterType="string" resultType="string">
SELECT password
FROM user
WHERE email = #{email}
</select>

<update id="updatePassword">
UPDATE user
SET password = #{newPassword}
WHERE email = #{email}
</update>

</mapper>
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;
Expand Down Expand Up @@ -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<String, String> 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());
}
Comment on lines +626 to +635
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spring rest api documentation을 위한 처리가 되어있지 않습니다. 확인해주세요

}
Loading