diff --git a/backend/build.gradle b/backend/build.gradle index c092c4a5..e8b27b94 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -52,6 +52,8 @@ dependencies { implementation group: 'com.github.vladimir-bukhtoyarov', name: 'bucket4j-core', version: '8.0.1' //aop implementation 'org.springframework.boot:spring-boot-starter-aop' + //email + implementation 'org.springframework.boot:spring-boot-starter-mail' } tasks.named('test') { diff --git a/backend/src/main/java/middle_point_search/backend/BackendApplication.java b/backend/src/main/java/middle_point_search/backend/BackendApplication.java index dc1b50ef..2d604e84 100644 --- a/backend/src/main/java/middle_point_search/backend/BackendApplication.java +++ b/backend/src/main/java/middle_point_search/backend/BackendApplication.java @@ -1,18 +1,21 @@ package middle_point_search.backend; -import io.swagger.v3.oas.annotations.OpenAPIDefinition; -import io.swagger.v3.oas.annotations.servers.Server; -import jakarta.annotation.PostConstruct; -import middle_point_search.backend.common.filter.RateLimitFilter; +import java.util.TimeZone; + import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.EnableAspectJAutoProxy; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; -import java.util.TimeZone; +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.servers.Server; +import jakarta.annotation.PostConstruct; +import middle_point_search.backend.common.filter.RateLimitFilter; +@EnableAsync @SpringBootApplication @EnableJpaAuditing @EnableAspectJAutoProxy diff --git a/backend/src/main/java/middle_point_search/backend/common/email/EmailConfig.java b/backend/src/main/java/middle_point_search/backend/common/email/EmailConfig.java new file mode 100644 index 00000000..aeaf3eca --- /dev/null +++ b/backend/src/main/java/middle_point_search/backend/common/email/EmailConfig.java @@ -0,0 +1,43 @@ +package middle_point_search.backend.common.email; + +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; + +import lombok.RequiredArgsConstructor; +import middle_point_search.backend.common.properties.EmailProperties; + +@Configuration +@RequiredArgsConstructor +public class EmailConfig { + + private final EmailProperties emailProperties; + + @Bean + public JavaMailSender javaMailSender() { + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost(emailProperties.getHost()); + mailSender.setPort(emailProperties.getPort()); + mailSender.setUsername(emailProperties.getUsername()); + mailSender.setPassword(emailProperties.getPassword()); + mailSender.setDefaultEncoding("UTF-8"); + mailSender.setJavaMailProperties(getMailProperties()); + + return mailSender; + } + + private Properties getMailProperties() { + Properties properties = new Properties(); + properties.put("mail.smtp.auth", true); + properties.put("mail.smtp.starttls.enable", true); + properties.put("mail.smtp.starttls.required", true); + properties.put("mail.smtp.connectiontimeout", emailProperties.getConnectionTimeout()); + properties.put("mail.smtp.timeout", emailProperties.getTimeout()); + properties.put("mail.smtp.writetimeout", emailProperties.getWriteTimeout()); + + return properties; + } +} diff --git a/backend/src/main/java/middle_point_search/backend/common/exception/errorCode/UserErrorCode.java b/backend/src/main/java/middle_point_search/backend/common/exception/errorCode/UserErrorCode.java index 703ca799..96cec4a9 100644 --- a/backend/src/main/java/middle_point_search/backend/common/exception/errorCode/UserErrorCode.java +++ b/backend/src/main/java/middle_point_search/backend/common/exception/errorCode/UserErrorCode.java @@ -19,6 +19,9 @@ public enum UserErrorCode implements ErrorCode { MEMBER_CREDENTIAL_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "M-002", "비밀번호가 일치하지 않습니다"), MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M-201", "존재하지 않는 회원입니다."), DUPLICATE_MEMBER_EMAIL(HttpStatus.CONFLICT, "M-001", "이미 존재하는 이메일입니다."), + REQUIRE_VERIFICATION_REQUEST_FIRST(HttpStatus.FORBIDDEN, "M-003", "이메일 인증을 먼저 진행해주세요."), + VERIFICATION_CODE_NOT_MATCH(HttpStatus.FORBIDDEN, "M-004", "인증 코드가 일치하지 않습니다."), + PASSWORD_NOT_MATCH(HttpStatus.FORBIDDEN, "M-005", "비밀번호가 일치하지 않습니다."), //방 관련 ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "R-201", "존재하지 않는 방입니다."), @@ -48,6 +51,9 @@ public enum UserErrorCode implements ErrorCode { //서버 관련 API_INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S-001", "API 서버에 문제가 발생하였습니다."), + + //이메일 관련 + EMAIL_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "E-001", "이메일 전송에 실패하였습니다."), ; private final HttpStatus httpStatus; diff --git a/backend/src/main/java/middle_point_search/backend/common/properties/EmailProperties.java b/backend/src/main/java/middle_point_search/backend/common/properties/EmailProperties.java new file mode 100644 index 00000000..e4cb92dc --- /dev/null +++ b/backend/src/main/java/middle_point_search/backend/common/properties/EmailProperties.java @@ -0,0 +1,20 @@ +package middle_point_search.backend.common.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@ConfigurationProperties(prefix = "mail") +public class EmailProperties { + + private String host; + private int port; + private String username; + private String password; + private int connectionTimeout; + private int timeout; + private int writeTimeout; +} diff --git a/backend/src/main/java/middle_point_search/backend/common/properties/conf/PropertyConfig.java b/backend/src/main/java/middle_point_search/backend/common/properties/conf/PropertyConfig.java index 16719b00..62fe9f64 100644 --- a/backend/src/main/java/middle_point_search/backend/common/properties/conf/PropertyConfig.java +++ b/backend/src/main/java/middle_point_search/backend/common/properties/conf/PropertyConfig.java @@ -3,13 +3,14 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; +import middle_point_search.backend.common.properties.CorsProperties; +import middle_point_search.backend.common.properties.EmailProperties; import middle_point_search.backend.common.properties.GoogleProperties; import middle_point_search.backend.common.properties.JwtProperties; import middle_point_search.backend.common.properties.KakaoProperties; import middle_point_search.backend.common.properties.MarketProperties; import middle_point_search.backend.common.properties.RedisProperties; import middle_point_search.backend.common.properties.SecurityProperties; -import middle_point_search.backend.common.properties.CorsProperties; // 전역적으로 사용되는 상수 @Configuration @@ -20,7 +21,8 @@ MarketProperties.class, KakaoProperties.class, RedisProperties.class, - GoogleProperties.class + GoogleProperties.class, + EmailProperties.class }) public class PropertyConfig { } diff --git a/backend/src/main/java/middle_point_search/backend/common/util/encoder/PasswordEncoderUtil.java b/backend/src/main/java/middle_point_search/backend/common/util/encoder/PasswordEncoderUtil.java deleted file mode 100644 index 1189b19a..00000000 --- a/backend/src/main/java/middle_point_search/backend/common/util/encoder/PasswordEncoderUtil.java +++ /dev/null @@ -1,18 +0,0 @@ -package middle_point_search.backend.common.util.encoder; - -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class PasswordEncoderUtil { - - private final PasswordEncoder passwordEncoder; - - // 패스워드 인코딩 - public String encodePassword(String rawPw) { - return passwordEncoder.encode(rawPw); - } -} diff --git a/backend/src/main/java/middle_point_search/backend/domains/email/domain/PasswordReissueVerificationCode.java b/backend/src/main/java/middle_point_search/backend/domains/email/domain/PasswordReissueVerificationCode.java new file mode 100644 index 00000000..c13ece10 --- /dev/null +++ b/backend/src/main/java/middle_point_search/backend/domains/email/domain/PasswordReissueVerificationCode.java @@ -0,0 +1,23 @@ +package middle_point_search.backend.domains.email.domain; + +import static lombok.AccessLevel.*; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = PROTECTED) +@RedisHash(value = "passwordReissueVerificationCode", timeToLive = 10 * 60) +public class PasswordReissueVerificationCode { + + @Id + private String email; + + @Setter + private String code; +} diff --git a/backend/src/main/java/middle_point_search/backend/domains/email/domain/SignupVerificationCode.java b/backend/src/main/java/middle_point_search/backend/domains/email/domain/SignupVerificationCode.java new file mode 100644 index 00000000..dc263581 --- /dev/null +++ b/backend/src/main/java/middle_point_search/backend/domains/email/domain/SignupVerificationCode.java @@ -0,0 +1,24 @@ +package middle_point_search.backend.domains.email.domain; + +import static lombok.AccessLevel.*; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = PROTECTED) +@RedisHash(value = "signupVerificationCode", timeToLive = 10 * 60) +public class SignupVerificationCode { + + @Id + private String email; + + @Setter + private String code; +} diff --git a/backend/src/main/java/middle_point_search/backend/domains/email/repository/PasswordReissueVerificationCodeRepository.java b/backend/src/main/java/middle_point_search/backend/domains/email/repository/PasswordReissueVerificationCodeRepository.java new file mode 100644 index 00000000..1689639a --- /dev/null +++ b/backend/src/main/java/middle_point_search/backend/domains/email/repository/PasswordReissueVerificationCodeRepository.java @@ -0,0 +1,8 @@ +package middle_point_search.backend.domains.email.repository; + +import org.springframework.data.repository.CrudRepository; + +import middle_point_search.backend.domains.email.domain.PasswordReissueVerificationCode; + +public interface PasswordReissueVerificationCodeRepository extends CrudRepository { +} diff --git a/backend/src/main/java/middle_point_search/backend/domains/email/repository/SignupVerificationCodeRepository.java b/backend/src/main/java/middle_point_search/backend/domains/email/repository/SignupVerificationCodeRepository.java new file mode 100644 index 00000000..8b6f8a5c --- /dev/null +++ b/backend/src/main/java/middle_point_search/backend/domains/email/repository/SignupVerificationCodeRepository.java @@ -0,0 +1,8 @@ +package middle_point_search.backend.domains.email.repository; + +import org.springframework.data.repository.CrudRepository; + +import middle_point_search.backend.domains.email.domain.SignupVerificationCode; + +public interface SignupVerificationCodeRepository extends CrudRepository { +} diff --git a/backend/src/main/java/middle_point_search/backend/domains/email/service/EmailService.java b/backend/src/main/java/middle_point_search/backend/domains/email/service/EmailService.java new file mode 100644 index 00000000..d717aa09 --- /dev/null +++ b/backend/src/main/java/middle_point_search/backend/domains/email/service/EmailService.java @@ -0,0 +1,39 @@ +package middle_point_search.backend.domains.email.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import middle_point_search.backend.domains.email.util.EmailUtil; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class EmailService { + + private final EmailUtil emailUtil; + + private static final String EMAIL_VERIFICATION_TITLE = "SyncSpot 이메일 인증 코드입니다."; + private static final String EMAIL_NEW_PASSWORD_TITLE = "SyncSpot 임시 비밀번호입니다."; + private static final String EMAIL_VERIFICATION_NOTICE_TEXT = "인증 코드는 %s 입니다."; + private static final String EMAIL_NEW_PASSWORD_NOTICE_TEXT = "임시 비밀번호는 %s 입니다. 로그인 후 비밀번호를 변경해주세요."; + + // 인증번호 이메일 보내기 + public void sendVerificationCodeEmail(String email, String code) { + String text = String.format(EMAIL_VERIFICATION_NOTICE_TEXT, code); + emailUtil.sendEmail(email, EMAIL_VERIFICATION_TITLE, text); + } + + // 비밀번호 재발급 인증번호 이메일 보내기 + public void sendPasswordReissueVerificationCodeEmail(String email, String code) { + String text = String.format(EMAIL_VERIFICATION_NOTICE_TEXT, code); + emailUtil.sendEmail(email, EMAIL_VERIFICATION_TITLE, text); + } + + // 새 비밀번호 이메일 보내기 + public void sendNewPassword(String email, String newPassword) { + String text = String.format(EMAIL_NEW_PASSWORD_NOTICE_TEXT, newPassword); + + emailUtil.sendEmail(email, EMAIL_NEW_PASSWORD_TITLE, text); + } +} diff --git a/backend/src/main/java/middle_point_search/backend/domains/email/service/PasswordReissueVerificationCodeService.java b/backend/src/main/java/middle_point_search/backend/domains/email/service/PasswordReissueVerificationCodeService.java new file mode 100644 index 00000000..77333ce2 --- /dev/null +++ b/backend/src/main/java/middle_point_search/backend/domains/email/service/PasswordReissueVerificationCodeService.java @@ -0,0 +1,61 @@ +package middle_point_search.backend.domains.email.service; + +import static middle_point_search.backend.common.exception.errorCode.UserErrorCode.*; + +import java.util.Random; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import middle_point_search.backend.common.exception.CustomException; +import middle_point_search.backend.domains.email.domain.PasswordReissueVerificationCode; +import middle_point_search.backend.domains.email.repository.PasswordReissueVerificationCodeRepository; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PasswordReissueVerificationCodeService { + + private final PasswordReissueVerificationCodeRepository passwordReissueVerificationCodeRepository; + + @Transactional + public void checkEmailCodeDuplicationAndSaveEmailCode(String email, String code) { + PasswordReissueVerificationCode passwordReissueVerificationCode = passwordReissueVerificationCodeRepository + .findById(email) + .orElse(null); + + // 1. 이미 저장된 emailCode가 있으면 code만 변경 + // 2. 없으면 새로 생성 + if (passwordReissueVerificationCode != null) { + passwordReissueVerificationCode.setCode(code); + } else { + passwordReissueVerificationCode = new PasswordReissueVerificationCode(email, code); + } + + passwordReissueVerificationCodeRepository.save(passwordReissueVerificationCode); + } + + // 인증 코드 생성 + public String createVerificationCode() { + Random random = new Random(); + + return String.format("%06d", random.nextInt(1000000)); // 000000부터 999999까지의 문자열 생성 + } + + // 인증 코드 확인 + public void verifyEmailCode(String email, String code) throws CustomException { + PasswordReissueVerificationCode passwordReissueVerificationCode = passwordReissueVerificationCodeRepository.findById( + email).orElse(null); + + if (passwordReissueVerificationCode == null) { + throw CustomException.from(REQUIRE_VERIFICATION_REQUEST_FIRST); + } + + if (!passwordReissueVerificationCode.getCode().equals(code)) { + throw CustomException.from(VERIFICATION_CODE_NOT_MATCH); + } + + passwordReissueVerificationCodeRepository.delete(passwordReissueVerificationCode); + } +} diff --git a/backend/src/main/java/middle_point_search/backend/domains/email/service/SignupVerificationCodeService.java b/backend/src/main/java/middle_point_search/backend/domains/email/service/SignupVerificationCodeService.java new file mode 100644 index 00000000..659521a9 --- /dev/null +++ b/backend/src/main/java/middle_point_search/backend/domains/email/service/SignupVerificationCodeService.java @@ -0,0 +1,64 @@ +package middle_point_search.backend.domains.email.service; + +import static middle_point_search.backend.common.exception.errorCode.UserErrorCode.*; + +import java.util.Random; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import middle_point_search.backend.common.exception.CustomException; +import middle_point_search.backend.domains.email.domain.SignupVerificationCode; +import middle_point_search.backend.domains.email.repository.SignupVerificationCodeRepository; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SignupVerificationCodeService { + + private final SignupVerificationCodeRepository signupVerificationCodeRepository; + + @Transactional + public void checkEmailCodeDuplicationAndSaveEmailCode(String email, String code) { + SignupVerificationCode signupVerificationCode = signupVerificationCodeRepository.findById(email).orElse(null); + + // 1. 이미 저장된 emailCode가 있으면 code만 변경 + // 2. 없으면 새로 생성 + if (signupVerificationCode != null) { + signupVerificationCode.setCode(code); + } else { + signupVerificationCode = new SignupVerificationCode(email, code); + } + + signupVerificationCodeRepository.save(signupVerificationCode); + } + + // 인증 코드 생성 + public String createVerificationCode() { + Random random = new Random(); + + return String.format("%06d", random.nextInt(1000000)); // 000000부터 999999까지의 문자열 생성 + } + + // 인증 코드 확인 + public boolean verifyEmailCode(String email, String code) throws CustomException { + SignupVerificationCode signupVerificationCode = signupVerificationCodeRepository.findById(email).orElse(null); + + if (signupVerificationCode == null) { + throw CustomException.from(REQUIRE_VERIFICATION_REQUEST_FIRST); + } + + return signupVerificationCode.getCode().equals(code); + } + + // 인증 코드 판별 후 삭제 + @Transactional + public void validateEmailCodeAndDelete(String email, String code) { + if (!verifyEmailCode(email, code)) { + throw CustomException.from(VERIFICATION_CODE_NOT_MATCH); + } + + signupVerificationCodeRepository.deleteById(email); + } +} diff --git a/backend/src/main/java/middle_point_search/backend/domains/email/util/EmailUtil.java b/backend/src/main/java/middle_point_search/backend/domains/email/util/EmailUtil.java new file mode 100644 index 00000000..99b9d127 --- /dev/null +++ b/backend/src/main/java/middle_point_search/backend/domains/email/util/EmailUtil.java @@ -0,0 +1,67 @@ +package middle_point_search.backend.domains.email.util; + +import static middle_point_search.backend.common.exception.errorCode.UserErrorCode.*; + +import org.springframework.mail.MailException; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import middle_point_search.backend.common.exception.CustomException; + +@Component +@RequiredArgsConstructor +public class EmailUtil { + + private final JavaMailSender emailSender; + + // 발신할 이메일 데이터 세팅 + private SimpleMailMessage createEmailForm( + String toEmail, + String title, + String text + ) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(toEmail); + message.setSubject(title); + message.setText(text); + + return message; + } + + // 이메일 보내기 + @Async + public void sendEmail( + String toEmail, + String title, + String text + ) { + SimpleMailMessage emailForm = createEmailForm(toEmail, title, text); + + int maxRetries = 3; + int retryCount = 0; + boolean success = false; + + // 이메일 전송 및 실패시 재시도 + while (retryCount < maxRetries && !success) { + try { + emailSender.send(emailForm); // 이메일 전송 + success = true; + } catch (MailException ex) { + retryCount++; + try { + Thread.sleep(5000); // 5초 대기 후 재시도 + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + } + + // 3번 시도 후 실패시 에러 발생 + if (!success) { + throw CustomException.from(EMAIL_SEND_FAILED); + } + } +} diff --git a/backend/src/main/java/middle_point_search/backend/domains/member/controller/MemberController.java b/backend/src/main/java/middle_point_search/backend/domains/member/controller/MemberController.java index f223d9c9..49141569 100644 --- a/backend/src/main/java/middle_point_search/backend/domains/member/controller/MemberController.java +++ b/backend/src/main/java/middle_point_search/backend/domains/member/controller/MemberController.java @@ -2,6 +2,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -21,6 +22,12 @@ import middle_point_search.backend.common.security.filter.jwtFilter.JwtTokenProvider; import middle_point_search.backend.common.util.MemberLoader; import middle_point_search.backend.domains.member.dto.MemberDTO.MemberCreateRequest; +import middle_point_search.backend.domains.member.dto.request.SendEmailVerificationRequest; +import middle_point_search.backend.domains.member.dto.request.SendNewPasswordRequest; +import middle_point_search.backend.domains.member.dto.request.SendPasswordReissueVerificationRequest; +import middle_point_search.backend.domains.member.dto.request.UpdatePasswordRequest; +import middle_point_search.backend.domains.member.dto.request.VerifyEmailVerificationCodeRequest; +import middle_point_search.backend.domains.member.dto.response.VerifyEmailVerificationCodeResponse; import middle_point_search.backend.domains.member.service.MemberService; @Tag(name = "MEMBER API", description = "회원에 대한 API입니다.") @@ -39,7 +46,7 @@ public class MemberController { description = """ 회원가입한다. - 이름, 이메일, 비밀번호를 입력받아 회원가입한다.""", + 이름, 이메일, 비밀번호, 주소, 인증 코드를 입력받아 회원가입한다.""", responses = { @ApiResponse( responseCode = "200", @@ -50,6 +57,16 @@ public class MemberController { description = "이미 존재하는 이메일입니다.[M-001]", content = @Content(schema = @Schema(implementation = ErrorResponse.class)) ), + @ApiResponse( + responseCode = "403", + description = "인증 코드가 일치하지 않습니다.[M-004]", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "이메일 인증을 먼저 진행해주세요.[M-003]", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) } ) public ResponseEntity> memberCreate(@RequestBody @Valid MemberCreateRequest request) { @@ -113,5 +130,164 @@ public ResponseEntity> loginMember( // 이 메소드는 실제로 실행되지 않습니다. 문서용도로만 사용됩니다. return ResponseEntity.ok(DataResponse.ok()); } + + @PostMapping("/verification-request/signup") + @Operation( + summary = "회원가입 email 인증 요청", + description = """ + 이메일을 입력받아 인증코드를 전송합니다. + 이메일 인증 확인 API로 타당한 인증코드인지 확인합니다. + 회원가입 시 인증코드를 함께 보냅니다. + """, + responses = { + @ApiResponse( + responseCode = "200", + description = "성공" + ), + @ApiResponse( + responseCode = "409", + description = "이미 존재하는 이메일입니다.[M-001]", + content = @Content(schema = @Schema(implementation = org.springframework.web.ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "500", + description = "이메일 전송에 실패하였습니다.[E-001]", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + } + ) + public ResponseEntity> sendEmailVerification( + @RequestBody @Valid SendEmailVerificationRequest request + ) { + memberService.validateDuplicatedEmailAndSendEmailVerification(request); + + return ResponseEntity.ok(DataResponse.ok()); + } + + @PostMapping("/verification/signup") + @Operation( + summary = "회원가입 email 인증", + description = "email 인증을 확인", + responses = { + @ApiResponse( + responseCode = "200", + description = "성공" + ), + @ApiResponse( + responseCode = "403", + description = "이메일 인증을 먼저 진행해주세요.[M-003]", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + } + ) + public ResponseEntity> verifyEmailVerificationCode( + @RequestBody @Valid VerifyEmailVerificationCodeRequest request + ) { + VerifyEmailVerificationCodeResponse response = memberService.verifyEmailVerificationCode(request); + + return ResponseEntity.ok(DataResponse.from(response)); + } + + @PatchMapping("/password") + @Operation( + summary = "비밀번호 수정", + description = "비밀번호 수정", + responses = { + @ApiResponse( + responseCode = "200", + description = "성공" + ), + @ApiResponse( + responseCode = "401", + description = "인증에 실패하였습니다.[C-101]", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "402", + description = "Access Token을 재발급해야합니다.[A-004]", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "비밀번호가 일치하지 않습니다.[M-005]", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + } + ) + public ResponseEntity> updatePassword( + @Valid @RequestBody UpdatePasswordRequest updatePasswordRequest + ) { + Long memberId = memberLoader.getMemberId(); + + memberService.updatePassword(memberId, updatePasswordRequest.password(), updatePasswordRequest.newPassword()); + + return ResponseEntity.ok(DataResponse.ok()); + } + + @PostMapping("/verification-request/password-reissue") + @Operation( + summary = "비밀번호 재발급 email 인증 요청", + description = """ + 이메일을 입력받아 비밀번호 재발급 인증코드를 전송합니다. + """, + responses = { + @ApiResponse( + responseCode = "200", + description = "성공" + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 회원입니다.[M-002]", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "500", + description = "이메일 전송에 실패하였습니다.[E-001]", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + } + ) + public ResponseEntity> sendPasswordReissueVerification( + @RequestBody @Valid SendPasswordReissueVerificationRequest request + ) { + memberService.sendPasswordReissueVerification(request); + + return ResponseEntity.ok(DataResponse.ok()); + } + + @PostMapping("/password-reissue") + @Operation( + summary = "비밀번호 재발급", + description = """ + 인증 번호를 통해 비밀번호를 재발급한다.""", + responses = { + @ApiResponse( + responseCode = "200", + description = "성공" + ), + @ApiResponse( + responseCode = "403", + description = "이메일 인증을 먼저 진행해주세요.[M-003]", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "인증 코드가 일치하지 않습니다.[M-004]", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "500", + description = "이메일 전송에 실패하였습니다.[E-001]", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + } + ) + public ResponseEntity> sendNewPassword( + @RequestBody @Valid SendNewPasswordRequest request + ) { + memberService.validateCodeAndSendNewPassword(request); + + return ResponseEntity.ok(DataResponse.ok()); + } } diff --git a/backend/src/main/java/middle_point_search/backend/domains/member/domain/Member.java b/backend/src/main/java/middle_point_search/backend/domains/member/domain/Member.java index cb24c26e..4ca42ada 100644 --- a/backend/src/main/java/middle_point_search/backend/domains/member/domain/Member.java +++ b/backend/src/main/java/middle_point_search/backend/domains/member/domain/Member.java @@ -93,4 +93,10 @@ public static Member createWithAddress( return new Member(email, pw, name, role, true, siDo, siGunGu, roadNameAddress, addressLatitude, addressLongitude); } + + // 비밀번호 변경 + public void updatePassword(String encodedPassword) { + this.pw = encodedPassword; + + } } diff --git a/backend/src/main/java/middle_point_search/backend/domains/member/dto/MemberDTO.java b/backend/src/main/java/middle_point_search/backend/domains/member/dto/MemberDTO.java index f410b8c3..30678103 100644 --- a/backend/src/main/java/middle_point_search/backend/domains/member/dto/MemberDTO.java +++ b/backend/src/main/java/middle_point_search/backend/domains/member/dto/MemberDTO.java @@ -31,5 +31,9 @@ public static class MemberCreateRequest { private String roadNameAddress; private Double addressLatitude; private Double addressLongitude; + + //인증코드 + @NotBlank(message = "인증코드를 입력해주세요.") + private String code; } } diff --git a/backend/src/main/java/middle_point_search/backend/domains/member/dto/request/SendEmailVerificationRequest.java b/backend/src/main/java/middle_point_search/backend/domains/member/dto/request/SendEmailVerificationRequest.java new file mode 100644 index 00000000..4e9f0281 --- /dev/null +++ b/backend/src/main/java/middle_point_search/backend/domains/member/dto/request/SendEmailVerificationRequest.java @@ -0,0 +1,16 @@ +package middle_point_search.backend.domains.member.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SendEmailVerificationRequest { + + @NotBlank(message = "이메일은 필수값입니다.") + @Email(message = "이메일 형식이 아닙니다.") + private String email; +} diff --git a/backend/src/main/java/middle_point_search/backend/domains/member/dto/request/SendNewPasswordRequest.java b/backend/src/main/java/middle_point_search/backend/domains/member/dto/request/SendNewPasswordRequest.java new file mode 100644 index 00000000..f97fdd49 --- /dev/null +++ b/backend/src/main/java/middle_point_search/backend/domains/member/dto/request/SendNewPasswordRequest.java @@ -0,0 +1,19 @@ +package middle_point_search.backend.domains.member.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SendNewPasswordRequest { + + @NotBlank(message = "email은 비어있을 수 없습니다.") + @Email(message = "email 형식이 올바르지 않습니다.") + private String email; + + @NotBlank(message = "code는 비어있을 수 없습니다.") + private String code; +} diff --git a/backend/src/main/java/middle_point_search/backend/domains/member/dto/request/SendPasswordReissueVerificationRequest.java b/backend/src/main/java/middle_point_search/backend/domains/member/dto/request/SendPasswordReissueVerificationRequest.java new file mode 100644 index 00000000..411e2d2e --- /dev/null +++ b/backend/src/main/java/middle_point_search/backend/domains/member/dto/request/SendPasswordReissueVerificationRequest.java @@ -0,0 +1,16 @@ +package middle_point_search.backend.domains.member.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SendPasswordReissueVerificationRequest { + + @NotBlank(message = "이메일은 필수값입니다.") + @Email(message = "이메일 형식이 아닙니다.") + private String email; +} diff --git a/backend/src/main/java/middle_point_search/backend/domains/member/dto/request/UpdatePasswordRequest.java b/backend/src/main/java/middle_point_search/backend/domains/member/dto/request/UpdatePasswordRequest.java new file mode 100644 index 00000000..641256fa --- /dev/null +++ b/backend/src/main/java/middle_point_search/backend/domains/member/dto/request/UpdatePasswordRequest.java @@ -0,0 +1,11 @@ +package middle_point_search.backend.domains.member.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record UpdatePasswordRequest( + @NotBlank(message = "현재 비밀번호를 입력해주세요.") + String password, + @NotBlank(message = "새 비밀번호를 입력해주세요.") + String newPassword +) { +} diff --git a/backend/src/main/java/middle_point_search/backend/domains/member/dto/request/VerifyEmailVerificationCodeRequest.java b/backend/src/main/java/middle_point_search/backend/domains/member/dto/request/VerifyEmailVerificationCodeRequest.java new file mode 100644 index 00000000..0a310491 --- /dev/null +++ b/backend/src/main/java/middle_point_search/backend/domains/member/dto/request/VerifyEmailVerificationCodeRequest.java @@ -0,0 +1,19 @@ +package middle_point_search.backend.domains.member.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class VerifyEmailVerificationCodeRequest { + + @NotBlank(message = "이메일은 필수값입니다.") + @Email(message = "이메일 형식이 아닙니다.") + private String email; + + @NotBlank(message = "인증코드는 필수값입니다.") + private String code; +} diff --git a/backend/src/main/java/middle_point_search/backend/domains/member/dto/response/VerifyEmailVerificationCodeResponse.java b/backend/src/main/java/middle_point_search/backend/domains/member/dto/response/VerifyEmailVerificationCodeResponse.java new file mode 100644 index 00000000..401af1fa --- /dev/null +++ b/backend/src/main/java/middle_point_search/backend/domains/member/dto/response/VerifyEmailVerificationCodeResponse.java @@ -0,0 +1,4 @@ +package middle_point_search.backend.domains.member.dto.response; + +public record VerifyEmailVerificationCodeResponse(Boolean isVerified) { +} diff --git a/backend/src/main/java/middle_point_search/backend/domains/member/service/MemberService.java b/backend/src/main/java/middle_point_search/backend/domains/member/service/MemberService.java index a8646c0b..556fc490 100644 --- a/backend/src/main/java/middle_point_search/backend/domains/member/service/MemberService.java +++ b/backend/src/main/java/middle_point_search/backend/domains/member/service/MemberService.java @@ -2,18 +2,28 @@ import static middle_point_search.backend.common.exception.errorCode.UserErrorCode.*; +import java.util.UUID; + +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import middle_point_search.backend.common.exception.CustomException; -import middle_point_search.backend.common.util.encoder.PasswordEncoderUtil; +import middle_point_search.backend.domains.email.service.EmailService; +import middle_point_search.backend.domains.email.service.PasswordReissueVerificationCodeService; +import middle_point_search.backend.domains.email.service.SignupVerificationCodeService; import middle_point_search.backend.domains.logout.LogoutService; import middle_point_search.backend.domains.logout.LogoutToken; import middle_point_search.backend.domains.member.domain.Member; import middle_point_search.backend.domains.member.domain.Role; import middle_point_search.backend.domains.member.dto.MemberDTO.MemberCreateRequest; +import middle_point_search.backend.domains.member.dto.request.SendEmailVerificationRequest; +import middle_point_search.backend.domains.member.dto.request.SendNewPasswordRequest; +import middle_point_search.backend.domains.member.dto.request.SendPasswordReissueVerificationRequest; +import middle_point_search.backend.domains.member.dto.request.VerifyEmailVerificationCodeRequest; +import middle_point_search.backend.domains.member.dto.response.VerifyEmailVerificationCodeResponse; import middle_point_search.backend.domains.member.repository.MemberRepository; import middle_point_search.backend.domains.refreshToken.RefreshTokenService; @@ -24,16 +34,20 @@ public class MemberService { private final MemberRepository memberRepository; - private final PasswordEncoderUtil passwordEncoderUtil; + private final PasswordEncoder passwordEncoder; private final RefreshTokenService refreshTokenService; private final LogoutService logoutService; + private final SignupVerificationCodeService signupVerificationCodeService; + private final EmailService emailService; + private final PasswordReissueVerificationCodeService passwordReissueVerificationCodeService; // 회원가입하기 @Transactional public void createMember(MemberCreateRequest request) { validateExistingEmail(request.getEmail()); + signupVerificationCodeService.validateEmailCodeAndDelete(request.getEmail(), request.getCode()); - String pw = passwordEncoderUtil.encodePassword(request.getPw()); + String pw = passwordEncoder.encode(request.getPw()); Member member = createMemberEntity(request, pw); @@ -61,7 +75,7 @@ private Member createMemberEntity(MemberCreateRequest request, String pw) { // 중복 회원 체크하기 private void validateExistingEmail(String email) { - if(memberRepository.existsByEmail(email)) { + if (memberRepository.existsByEmail(email)) { throw CustomException.from(DUPLICATE_MEMBER_EMAIL); } } @@ -75,4 +89,89 @@ public void logoutMember(Long memberId, String accessToken) { // 같은 accessToken으로 다시 로그인하지 못하도록 블랙리스트에 저장 logoutService.save(new LogoutToken(accessToken)); } + + // 이메일 중복 체크 및 인증 이메일 보내기 + public void validateDuplicatedEmailAndSendEmailVerification( + SendEmailVerificationRequest request + ) { + String email = request.getEmail(); + + validateExistingEmail(email); + + String verificationCode = signupVerificationCodeService.createVerificationCode(); + signupVerificationCodeService.checkEmailCodeDuplicationAndSaveEmailCode(email, verificationCode); + emailService.sendVerificationCodeEmail(email, verificationCode); + } + + // 인증 코드 인증 + public VerifyEmailVerificationCodeResponse verifyEmailVerificationCode( + VerifyEmailVerificationCodeRequest request + ) { + boolean isVerified = signupVerificationCodeService.verifyEmailCode(request.getEmail(), request.getCode()); + + return new VerifyEmailVerificationCodeResponse(isVerified); + } + + // 비밀번호 변경 + @Transactional + public void updatePassword(Long memberId, String password, String newPassword) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> CustomException.from(MEMBER_NOT_FOUND)); + + checkPassword(password, member.getPw()); + + member.updatePassword(passwordEncoder.encode(newPassword)); + } + + // 비밀번호 일치 확인 + private void checkPassword(String password, String encodedPassword) { + boolean matches = passwordEncoder.matches(password, encodedPassword); + + if (!matches) { + throw CustomException.from(PASSWORD_NOT_MATCH); + } + } + + // 비밀번호 재발급 + @Transactional + public void validateCodeAndSendNewPassword(SendNewPasswordRequest request) { + String email = request.getEmail(); + + // 토큰 검증 및 삭제 + passwordReissueVerificationCodeService.verifyEmailCode(email, request.getCode()); + + // 맞는 게 있다면 그 member 비밀번호 변경 및 전송 + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> CustomException.from(MEMBER_NOT_FOUND)); + + // 비밀번호 변경 + String newPassword = createNewPassword(); + member.updatePassword(passwordEncoder.encode(newPassword)); + + // 새 비밀번호 이메일 전송 + emailService.sendNewPassword(email, newPassword); + } + + // 새 비밀번호 생성 + private String createNewPassword() { + // UUID 생성 + String uuid = UUID.randomUUID().toString().replace("-", ""); + + // 첫 6자 추출 + return uuid.substring(0, 6); + } + + // 비밀번호 재발급 인증 코드 보내기 + public void sendPasswordReissueVerification(SendPasswordReissueVerificationRequest request) { + // 존재하는 회원이 아니면 에러 + if (!memberRepository.existsByEmail(request.getEmail())) { + throw CustomException.from(MEMBER_NOT_FOUND); + } + + String code = passwordReissueVerificationCodeService.createVerificationCode(); + passwordReissueVerificationCodeService.checkEmailCodeDuplicationAndSaveEmailCode(request.getEmail(), code); + emailService.sendPasswordReissueVerificationCodeEmail(request.getEmail(), code); + } + } +