diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/application/dto/request/PwdResetRequest.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/application/dto/request/PwdResetRequest.java new file mode 100644 index 0000000..73ad85c --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/application/dto/request/PwdResetRequest.java @@ -0,0 +1,13 @@ +package com.whereyouad.WhereYouAd.domains.user.application.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record PwdResetRequest( + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email, + @NotBlank(message = "비밀번호는 필수입니다.") + String password +) { +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/constant/Provider.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/constant/Provider.java index bf84f27..9805402 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/constant/Provider.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/constant/Provider.java @@ -1,6 +1,6 @@ package com.whereyouad.WhereYouAd.domains.user.domain.constant; -import com.whereyouad.WhereYouAd.domains.user.exception.UserSignUpException; +import com.whereyouad.WhereYouAd.domains.user.exception.handler.UserHandler; import com.whereyouad.WhereYouAd.domains.user.exception.code.UserErrorCode; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -22,6 +22,6 @@ public static Provider fromRegistrationId(String registrationId) { return Arrays.stream(Provider.values()) .filter(socialType -> socialType.getRegistrationId().equals(registrationId)) .findFirst() - .orElseThrow(() -> new UserSignUpException(UserErrorCode.NOT_PROVIDE_SOCIAL)); + .orElseThrow(() -> new UserHandler(UserErrorCode.NOT_PROVIDE_SOCIAL)); } } diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/EmailService.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/EmailService.java index abb31ce..ad7ff56 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/EmailService.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/EmailService.java @@ -1,7 +1,7 @@ package com.whereyouad.WhereYouAd.domains.user.domain.service; import com.whereyouad.WhereYouAd.domains.user.application.dto.response.EmailSentResponse; -import com.whereyouad.WhereYouAd.domains.user.exception.UserSignUpException; +import com.whereyouad.WhereYouAd.domains.user.exception.handler.UserHandler; import com.whereyouad.WhereYouAd.domains.user.exception.code.UserErrorCode; import com.whereyouad.WhereYouAd.domains.user.persistence.repository.UserRepository; import com.whereyouad.WhereYouAd.global.utils.RedisUtil; @@ -28,12 +28,29 @@ public class EmailService { @Value("${spring.mail.username}") private String senderEmail; - //인증코드 이메일 발송 로직 + //인증코드 이메일 발송 로직 (최초 회원가입 시) public EmailSentResponse sendEmail(String toEmail) { - if (userRepository.existsByEmail(toEmail)) { //이미 해당 이메일로 생성한 계정이 있으면 - throw new UserSignUpException(UserErrorCode.USER_EMAIL_DUPLICATE); //이메일 중복 예외(회원가입 시 사용했던 예외) + throw new UserHandler(UserErrorCode.USER_EMAIL_DUPLICATE); //이메일 중복 예외(회원가입 시 사용했던 예외) + } + + String type = "회원가입"; + + return emailSendTemplate(toEmail, type); + } + + //비밀번호 재설정을 위한 인증코드 이메일 발송 로직 (이미 회원가입 된 상태에서 비밀번호 재설정) + public EmailSentResponse sendEmailForPwd(String toEmail) { + if (userRepository.existsByEmail(toEmail)) { //이미 회원가입 되어있는 것이 확인되면 + String type = "비밀번호 재설정"; + return emailSendTemplate(toEmail, type); //정상적으로 이메일 발송 + } else { //만약 회원가입 되어있지 않다면 + throw new UserHandler(UserErrorCode.USER_NOT_FOUND); //예외발생 } + } + + //기존 이메일 발송 로직 템플릿 화 + private EmailSentResponse emailSendTemplate(String toEmail, String type) { //인증코드 재전송 로직 -> 이미 Redis 에 해당 이메일 인증코드가 있을시 삭제 String redisKey = "CODE:" + toEmail; @@ -58,23 +75,24 @@ public EmailSentResponse sendEmail(String toEmail) { //실제 인증 코드가 담긴 이메일 전송 SimpleMailMessage message = new SimpleMailMessage(); message.setTo(toEmail); - message.setSubject("whereyouad 회원가입 인증번호"); - message.setText("인증번호: " + authCode); + //어떤 유형의 인증(최초 회원가입 or 비밀번호 재설정) 인지 구분하여 인증코드 발송 + message.setSubject("whereyouad " + type + " 인증번호"); + message.setText("[Where You Ad] " + type + "\n 인증 번호는 [" + authCode + "] 입니다."); message.setFrom(senderEmail); emailSender.send(message); //만약 실제 존재하는 이메일인데 사용자가 오타를 냈다면 } catch (MailException e) { //예외 발생 - throw new UserSignUpException(UserErrorCode.USER_EMAIL_NOT_VALID); //통합 응답 처리 예외로 반환 + throw new UserHandler(UserErrorCode.USER_EMAIL_NOT_VALID); //통합 응답 처리 예외로 반환 } } - //Redis에 저장 (Key: "CODE:이메일", Value: "123456", 유효시간: 300초(5분)) + //Redis에 저장 (Key: "CODE:이메일", Value: "123456", 유효시간: 180초(3분)) //테스트 계정도 인증은 해야하니 Redis 에 코드가 저장 되어야 함. //테스트 계정의 인증은 서버 로그를 통해 인증코드를 얻어 입력. - redisUtil.setDataExpire("CODE:" + toEmail, authCode, 60 * 5L); + redisUtil.setDataExpire("CODE:" + toEmail, authCode, 60 * 3L); - return new EmailSentResponse("인증코드를 이메일로 전송했습니다.", toEmail, 300L); + return new EmailSentResponse("인증코드를 이메일로 전송했습니다.", toEmail, 180L); } //인증코드 검증 메서드 @@ -85,7 +103,7 @@ public void verifyEmailCode(String email, String inputCode) { //만약 인증코드가 없거나 잘못 입력했다면, if (savedCode == null || !savedCode.equals(inputCode)) { - throw new UserSignUpException(UserErrorCode.USER_EMAIL_AUTH_INVALID); //예외 발생(BAD_REQUEST) + throw new UserHandler(UserErrorCode.USER_EMAIL_AUTH_INVALID); //예외 발생(BAD_REQUEST) } //정상적으로 인증코드를 입력했다면, diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/UserService.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/UserService.java index c347422..c9fa01e 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/UserService.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/UserService.java @@ -1,6 +1,6 @@ package com.whereyouad.WhereYouAd.domains.user.domain.service; -import com.whereyouad.WhereYouAd.domains.user.exception.UserSignUpException; +import com.whereyouad.WhereYouAd.domains.user.exception.handler.UserHandler; import com.whereyouad.WhereYouAd.domains.user.exception.code.UserErrorCode; import com.whereyouad.WhereYouAd.domains.user.domain.constant.UserStatus; import com.whereyouad.WhereYouAd.domains.user.application.mapper.UserConverter; @@ -26,7 +26,7 @@ public class UserService { //회원가입 메서드 public SignUpResponse signUpUser(SignUpRequest request) { if (userRepository.existsByEmail(request.email())) { //이미 이메일로 만든 계정이 존재할 시 - throw new UserSignUpException(UserErrorCode.USER_EMAIL_DUPLICATE); //이메일 중복 예외 + throw new UserHandler(UserErrorCode.USER_EMAIL_DUPLICATE); //이메일 중복 예외 } //추가 : 이메일 인증되었는지 확인 -> 악의적 공격자가 이메일 인증을 건너뛰고 회원가입 URL 등으로 바로 들어왔을 경우 @@ -35,7 +35,7 @@ public SignUpResponse signUpUser(SignUpRequest request) { //인증이 안되었다면, if (isEmailVerified == null || !isEmailVerified.equals("TRUE")) { - throw new UserSignUpException(UserErrorCode.USER_EMAIL_NOT_VERIFIED); //예외 발생(UNAUTHORIZED) + throw new UserHandler(UserErrorCode.USER_EMAIL_NOT_VERIFIED); //예외 발생(UNAUTHORIZED) } //비밀번호 암호화 -> SecurityConfig 클래스 내 에서 BCryptPasswordEncoder 를 Bean 등록한거로 사용 @@ -57,4 +57,32 @@ public SignUpResponse signUpUser(SignUpRequest request) { //Response DTO 로 변환 및 반환 return UserConverter.toSignInResponse(savedUser); } + + //이미 회원가입 된 회원의 비밀번호 재설정 메서드 + public void passwordReset(String email, String password) { + //이메일 인증이 되어있는지 확인 + String isEmailVerified = redisUtil.getData("VERIFIED:" + email); + + //인증이 안되었다면, + if (isEmailVerified == null || !isEmailVerified.equals("TRUE")) { + throw new UserHandler(UserErrorCode.USER_EMAIL_NOT_VERIFIED); //예외 발생 + } + + //기존 비밀번호와 새 비밀번호가 일치할 시 예외 발생 + User user = userRepository.findUserByEmail(email) + .orElseThrow(() -> new UserHandler(UserErrorCode.USER_NOT_FOUND)); + + String oldPassword = user.getPassword(); + + if (passwordEncoder.matches(password, oldPassword)) { //새로운 비밀번호 == 이전 비밀번호이면 + throw new UserHandler(UserErrorCode.USER_PASSWORD_SAME_AS_OLD); //예외 발생 + } + + //새 비밀번호 암호화 & 저장 + String newPassword = passwordEncoder.encode(password); + //비밀번호 변경 (JPA Dirty Checking) + user.resetPassword(newPassword); + + redisUtil.deleteData("VERIFIED:" + email); + } } diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/UserSignUpException.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/UserSignUpException.java deleted file mode 100644 index 6042838..0000000 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/UserSignUpException.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.whereyouad.WhereYouAd.domains.user.exception; - -import com.whereyouad.WhereYouAd.global.exception.AppException; -import com.whereyouad.WhereYouAd.global.exception.BaseErrorCode; - -public class UserSignUpException extends AppException { - public UserSignUpException(BaseErrorCode errorCode) { - super(errorCode); - } -} diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/code/UserErrorCode.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/code/UserErrorCode.java index 260cf86..2ac2870 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/code/UserErrorCode.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/code/UserErrorCode.java @@ -13,6 +13,7 @@ public enum UserErrorCode implements BaseErrorCode { USER_EMAIL_NOT_VALID(HttpStatus.BAD_REQUEST, "USER_400_2", "해당 이메일로 메일 전송에 실패했습니다."), USER_EMAIL_AUTH_INVALID(HttpStatus.BAD_REQUEST, "USER_400_3", "인증 코드가 올바르지 않습니다."), NOT_PROVIDE_SOCIAL(HttpStatus.BAD_REQUEST, "USER_400_4", "지원하지 않는 소셜 로그인 방식입니다."), + USER_PASSWORD_SAME_AS_OLD(HttpStatus.BAD_REQUEST, "USER_400_5", "이전 비밀번호와 동일한 비밀번호로 바꿀 수 없습니다."), // 401 USER_EMAIL_NOT_VERIFIED(HttpStatus.UNAUTHORIZED, "USER_401_1", "이메일 인증이 진행되지 않았습니다."), diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/handler/UserHandler.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/handler/UserHandler.java new file mode 100644 index 0000000..e996f24 --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/handler/UserHandler.java @@ -0,0 +1,10 @@ +package com.whereyouad.WhereYouAd.domains.user.exception.handler; + +import com.whereyouad.WhereYouAd.global.exception.AppException; +import com.whereyouad.WhereYouAd.global.exception.BaseErrorCode; + +public class UserHandler extends AppException { + public UserHandler(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/persistence/entity/User.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/persistence/entity/User.java index 3407afb..33aab14 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/user/persistence/entity/User.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/persistence/entity/User.java @@ -47,4 +47,9 @@ public class User extends BaseEntity { public void updateProfile(String name){ this.name = name; } + + + public void resetPassword(String password) { + this.password = password; + } } diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/UserController.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/UserController.java index b0c6d39..78f5163 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/UserController.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/UserController.java @@ -1,6 +1,7 @@ package com.whereyouad.WhereYouAd.domains.user.presentation; import com.whereyouad.WhereYouAd.domains.user.application.dto.request.EmailRequest; +import com.whereyouad.WhereYouAd.domains.user.application.dto.request.PwdResetRequest; import com.whereyouad.WhereYouAd.domains.user.application.dto.response.EmailSentResponse; import com.whereyouad.WhereYouAd.domains.user.domain.service.EmailService; import com.whereyouad.WhereYouAd.domains.user.domain.service.UserService; @@ -48,4 +49,22 @@ public ResponseEntity> verifyEmail(@RequestBody @Valid Emai DataResponse.from("이메일 인증이 성공적으로 완료되었습니다.") ); } + + @PostMapping("/password-reset/request") + public ResponseEntity> sendEmailForPwdReset(@RequestBody @Valid EmailRequest.Send request) { + EmailSentResponse emailSentResponse = emailService.sendEmailForPwd(request.email()); + + return ResponseEntity.ok( + DataResponse.from(emailSentResponse) + ); + } + + @PostMapping("/password-reset/confirm") + public ResponseEntity> resetPassword(@RequestBody @Valid PwdResetRequest request) { + userService.passwordReset(request.email(), request.password()); + + return ResponseEntity.ok( + DataResponse.from("비밀번호 변경이 완료되었습니다.") + ); + } } diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/docs/UserControllerDocs.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/docs/UserControllerDocs.java index 9048747..2eca79f 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/docs/UserControllerDocs.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/docs/UserControllerDocs.java @@ -1,6 +1,7 @@ package com.whereyouad.WhereYouAd.domains.user.presentation.docs; import com.whereyouad.WhereYouAd.domains.user.application.dto.request.EmailRequest; +import com.whereyouad.WhereYouAd.domains.user.application.dto.request.PwdResetRequest; import com.whereyouad.WhereYouAd.domains.user.application.dto.request.SignUpRequest; import com.whereyouad.WhereYouAd.domains.user.application.dto.response.EmailSentResponse; import com.whereyouad.WhereYouAd.domains.user.application.dto.response.SignUpResponse; @@ -19,7 +20,7 @@ public interface UserControllerDocs { ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "성공"), - @ApiResponse(responseCode = "400_2", description = "이메일 중복 회원 존재") + @ApiResponse(responseCode = "400_1", description = "이메일 중복 회원 존재") }) public ResponseEntity> signUp(@RequestBody @Valid SignUpRequest request); @@ -30,7 +31,7 @@ public interface UserControllerDocs { ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "성공"), - @ApiResponse(responseCode = "400_3", description = "이메일 전송실패(이메일 오타 등)") + @ApiResponse(responseCode = "400_2", description = "이메일 전송실패(이메일 오타 등)") }) public ResponseEntity> sendEmail(@RequestBody @Valid EmailRequest.Send request); @@ -40,7 +41,30 @@ public interface UserControllerDocs { ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "성공"), - @ApiResponse(responseCode = "400_4", description = "실패(인증코드 불일치)") + @ApiResponse(responseCode = "400_3", description = "실패(인증코드 불일치)") }) public ResponseEntity> verifyEmail(@RequestBody @Valid EmailRequest.Verify request); + + @Operation( + summary = "사용자 비밀번호 재설정을 위한 이메일 인증코드 전송 API", + description = "회원의 이메일을 입력받아 해당 이메일로 인증코드를 전송합니다(인증코드 검증은 기존 /email-verify 로 진행)" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "400_2", description = "이메일 전송실패(이메일 오타 등)"), + @ApiResponse(responseCode = "404_1", description = "해당 이메일로 가입한 회원 존재하지 않음") + }) + public ResponseEntity> sendEmailForPwdReset(@RequestBody @Valid EmailRequest.Send request); + + @Operation( + summary = "사용자 비밀번호 재설정 API", + description = "회원의 이메일과 재설정할 비밀번호를 입력받아 비밀번호를 재설정(이전과 같은 비밀번호 일 경우 예외 발생)" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "400_5", description = "이전 비밀번호와 동일 비밀번호로 변경 불가"), + @ApiResponse(responseCode = "401_1", description = "이메일 인증 진행되지 않음"), + @ApiResponse(responseCode = "404_1", description = "해당 이메일로 가입한 회원 존재하지 않음") + }) + public ResponseEntity> resetPassword(@RequestBody @Valid PwdResetRequest request); } diff --git a/src/main/java/com/whereyouad/WhereYouAd/global/security/oauth2/service/CustomOAuth2UserService.java b/src/main/java/com/whereyouad/WhereYouAd/global/security/oauth2/service/CustomOAuth2UserService.java index 0cbd2bd..42e1386 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/global/security/oauth2/service/CustomOAuth2UserService.java +++ b/src/main/java/com/whereyouad/WhereYouAd/global/security/oauth2/service/CustomOAuth2UserService.java @@ -1,8 +1,8 @@ package com.whereyouad.WhereYouAd.global.security.oauth2.service; +import com.whereyouad.WhereYouAd.domains.user.exception.handler.UserHandler; import com.whereyouad.WhereYouAd.global.security.oauth2.dto.*; import com.whereyouad.WhereYouAd.domains.user.domain.constant.Provider; -import com.whereyouad.WhereYouAd.domains.user.exception.UserSignUpException; import com.whereyouad.WhereYouAd.domains.user.exception.code.UserErrorCode; import com.whereyouad.WhereYouAd.domains.user.persistence.entity.AuthProviderAccount; import com.whereyouad.WhereYouAd.domains.user.persistence.entity.User; @@ -50,7 +50,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic } else if (provider == Provider.KAKAO){ oAuth2Response = new KaKaoResponse(oAuth2User.getAttributes()); } else { - throw new UserSignUpException(UserErrorCode.NOT_PROVIDE_SOCIAL); + throw new UserHandler(UserErrorCode.NOT_PROVIDE_SOCIAL); } // 사용자 소셜 로그인 고유 id (제공자 + 소셜 발급 id) -> (중복 회원 가입 방지) @@ -69,7 +69,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic user = userOptional.get(); // 기존 이메일에 해당하는 유저가 존재하지만 이메일 인증이 안된 경우 -> 연동 불가 if (!user.isEmailVerified()) { - throw new UserSignUpException(UserErrorCode.USER_EMAIL_NOT_VERIFIED); + throw new UserHandler(UserErrorCode.USER_EMAIL_NOT_VERIFIED); } } // 신규 유저(기존 email X, 소셜 로그인 처음) -> DB에 저장