diff --git a/build.gradle b/build.gradle index 46f4563..d211311 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,11 @@ dependencies { // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // Email + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/constants/EmailVerificationStorageConstants.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/constants/EmailVerificationStorageConstants.java new file mode 100644 index 0000000..0f3b0ed --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/constants/EmailVerificationStorageConstants.java @@ -0,0 +1,15 @@ +package org.withtime.be.withtimebe.domain.auth.constants; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.time.Duration; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class EmailVerificationStorageConstants { + + public static final String VERIFICATION_CODE_PREFIX = "EMAIL-VERIFICATION-CODE:"; + public static final String EMAIL_VERIFICATION_PREFIX = "EMAIL-VERIFICATION:"; + public static final Duration VERIFICATION_CODE_DURATION = Duration.ofMinutes(3); + public static final Duration EMAIL_VERIFICATION_DURATION = Duration.ofHours(1); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/controller/AuthController.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/controller/AuthController.java index ed2f300..2ad149b 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/auth/controller/AuthController.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/controller/AuthController.java @@ -11,7 +11,9 @@ import org.namul.api.payload.response.DefaultResponse; import org.springframework.web.bind.annotation.*; import org.withtime.be.withtimebe.domain.auth.dto.request.AuthRequestDTO; +import org.withtime.be.withtimebe.domain.auth.dto.request.EmailRequestDTO; import org.withtime.be.withtimebe.domain.auth.service.command.AuthCommandService; +import org.withtime.be.withtimebe.domain.auth.service.command.EmailCommandService; @RestController @RequiredArgsConstructor @@ -20,6 +22,7 @@ public class AuthController { private final AuthCommandService authCommandService; + private final EmailCommandService emailCommandService; @Operation(summary = "회원가입 API by 요시", description = "최초 회원가입 시 필요한 정보를 포함하여 회원가입 진행") @ApiResponses({ @@ -86,4 +89,32 @@ public DefaultResponse logout(HttpServletRequest request, HttpServletRes authCommandService.logout(request, response); return DefaultResponse.noContent(); } + + @Operation(summary = "이메일 인증 번호 전송 API by 요시", description = "이메일로 인증 번호를 전송하는 API") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "이메일 전송 성공, 인증 코드는 3분 동안 유효"), + @ApiResponse( + responseCode = "500", + description = "EMAIL500_1: 이메일 전송 실패" + ) + }) + @PostMapping("/email-verifications") + public DefaultResponse sendVerificationCodeToEmail(@Valid @RequestBody EmailRequestDTO.Send request) { + emailCommandService.sendEmail(request); + return DefaultResponse.noContent(); + } + + @Operation(summary = "이메일 인증 번호 확인 API by 요시" ,description = "이메일 인증 번호 확인 API") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "이메일 인증 성공, 성공 시 1시간 동안 유효"), + @ApiResponse( + responseCode = "401", + description = "EMAIL401_1: 이메일 인증 실패" + ) + }) + @PostMapping("/check-email-verifications") + public DefaultResponse checkVerificationCode(@Valid @RequestBody EmailRequestDTO.Check request) { + emailCommandService.checkEmail(request); + return DefaultResponse.noContent(); + } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/dto/request/EmailRequestDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/dto/request/EmailRequestDTO.java new file mode 100644 index 0000000..9d69684 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/dto/request/EmailRequestDTO.java @@ -0,0 +1,20 @@ +package org.withtime.be.withtimebe.domain.auth.dto.request; + + +import jakarta.validation.constraints.Email; + +public record EmailRequestDTO() { + + public record Send( + @Email + String email + ) { + } + + public record Check( + @Email + String email, + String code + ) { + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/generator/RandomGenerator.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/generator/RandomGenerator.java new file mode 100644 index 0000000..e5fc6a6 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/generator/RandomGenerator.java @@ -0,0 +1,6 @@ +package org.withtime.be.withtimebe.domain.auth.generator; + +public interface RandomGenerator { + + T generateRandom(); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/generator/RandomSixDigitGenerator.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/generator/RandomSixDigitGenerator.java new file mode 100644 index 0000000..cb780a4 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/generator/RandomSixDigitGenerator.java @@ -0,0 +1,17 @@ +package org.withtime.be.withtimebe.domain.auth.generator; + +import org.springframework.stereotype.Component; + +import java.security.SecureRandom; +import java.util.Random; + +@Component +public class RandomSixDigitGenerator implements RandomGenerator { + + private static final Random RANDOM = new SecureRandom(); + + @Override + public String generateRandom() { + return String.format("%06d", RANDOM.nextInt(1000000)); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/AuthCommandServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/AuthCommandServiceImpl.java index fe20212..48d7d10 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/AuthCommandServiceImpl.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/AuthCommandServiceImpl.java @@ -5,16 +5,20 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.withtime.be.withtimebe.domain.auth.converter.AuthConverter; import org.withtime.be.withtimebe.domain.auth.dto.request.AuthRequestDTO; +import org.withtime.be.withtimebe.domain.auth.service.query.EmailVerificationCodeStorageQueryService; import org.withtime.be.withtimebe.domain.auth.service.query.TokenQueryService; import org.withtime.be.withtimebe.domain.auth.service.query.TokenStorageQueryService; import org.withtime.be.withtimebe.domain.member.entity.Member; import org.withtime.be.withtimebe.domain.member.repository.MemberRepository; import org.withtime.be.withtimebe.global.error.code.AuthErrorCode; +import org.withtime.be.withtimebe.global.error.code.EmailErrorCode; import org.withtime.be.withtimebe.global.error.code.MemberErrorCode; import org.withtime.be.withtimebe.global.error.code.TokenErrorCode; import org.withtime.be.withtimebe.global.error.exception.AuthException; +import org.withtime.be.withtimebe.global.error.exception.EmailException; import org.withtime.be.withtimebe.global.error.exception.MemberException; import org.withtime.be.withtimebe.global.error.exception.TokenException; import org.withtime.be.withtimebe.global.security.constants.AuthenticationConstants; @@ -23,6 +27,7 @@ @Service @RequiredArgsConstructor +@Transactional public class AuthCommandServiceImpl implements AuthCommandService { private final PasswordEncoder passwordEncoder; @@ -31,6 +36,7 @@ public class AuthCommandServiceImpl implements AuthCommandService { private final TokenStorageCommandService tokenStorageCommandService; private final TokenQueryService tokenQueryService; private final TokenStorageQueryService tokenStorageQueryService; + private final EmailVerificationCodeStorageQueryService emailVerificationCodeStorageQueryService; @Override public void signUp(AuthRequestDTO.SignUp request) { @@ -87,6 +93,9 @@ private void validateSignUp(AuthRequestDTO.SignUp request) throws AuthException if (memberRepository.existsByEmail(request.email())) { throw new AuthException(AuthErrorCode.ALREADY_EXIST_EMAIL); } + if (!emailVerificationCodeStorageQueryService.isVerified(request.email())) { + throw new EmailException(EmailErrorCode.UNVERIFIED_EMAIL); + } } private Long getUserId(String token) { diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/EmailCommandService.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/EmailCommandService.java new file mode 100644 index 0000000..f8e5b10 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/EmailCommandService.java @@ -0,0 +1,8 @@ +package org.withtime.be.withtimebe.domain.auth.service.command; + +import org.withtime.be.withtimebe.domain.auth.dto.request.EmailRequestDTO; + +public interface EmailCommandService { + void sendEmail(EmailRequestDTO.Send request); + void checkEmail(EmailRequestDTO.Check request); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/EmailCommandServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/EmailCommandServiceImpl.java new file mode 100644 index 0000000..a3ff95e --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/EmailCommandServiceImpl.java @@ -0,0 +1,49 @@ +package org.withtime.be.withtimebe.domain.auth.service.command; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.auth.dto.request.EmailRequestDTO; +import org.withtime.be.withtimebe.domain.auth.generator.RandomGenerator; +import org.withtime.be.withtimebe.domain.auth.service.query.EmailVerificationCodeStorageQueryService; +import org.withtime.be.withtimebe.domain.auth.util.MailVerificationCodeSender; +import org.withtime.be.withtimebe.global.error.code.EmailErrorCode; +import org.withtime.be.withtimebe.global.error.exception.EmailException; + +@Service +@RequiredArgsConstructor +@Transactional +public class EmailCommandServiceImpl implements EmailCommandService { + + private final RandomGenerator randomSixDigitGenerator; + private final MailVerificationCodeSender mailVerificationCodeSender; + private final EmailVerificationCodeStorageCommandService emailVerificationCodeStorageCommandService; + private final EmailVerificationCodeStorageQueryService emailVerificationCodeStorageQueryService; + + @Override + public void sendEmail(EmailRequestDTO.Send request) { + String email = request.email(); + String code = randomSixDigitGenerator.generateRandom(); + + emailVerificationCodeStorageCommandService.saveVerificationCode(email, code); + try { + mailVerificationCodeSender.sendMail(email, code); + } catch (Exception e) { + emailVerificationCodeStorageCommandService.deleteVerificationCode(email); + throw e; + } + + } + + @Override + public void checkEmail(EmailRequestDTO.Check request) { + String email = request.email(); + if (emailVerificationCodeStorageQueryService.checkVerificationCode(email, request.code())) { + emailVerificationCodeStorageCommandService.saveVerifiedEmail(email); + } + else { + throw new EmailException(EmailErrorCode.INCORRECT_EMAIL_VERIFICATION_CODE); + } + emailVerificationCodeStorageCommandService.deleteVerificationCode(email); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/EmailVerificationCodeStorageCommandService.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/EmailVerificationCodeStorageCommandService.java new file mode 100644 index 0000000..d2c289f --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/EmailVerificationCodeStorageCommandService.java @@ -0,0 +1,28 @@ +package org.withtime.be.withtimebe.domain.auth.service.command; + +public interface EmailVerificationCodeStorageCommandService { + /** + * 인증 코드 저장 + * @param email 인증 코드를 확인할 이메일 + * @param verificationCode 이메일에 대한 인증 코드 + */ + void saveVerificationCode(String email, String verificationCode); + + /** + * 이메일에 대한 인증이 완료됨을 저장 + * @param email 인증이 완료됨을 저장할 이메일 + */ + void saveVerifiedEmail(String email); + + /** + * 이메일에 대한 인증 코드 삭제 + * @param email 인증 코드를 삭제할 이메일 + */ + void deleteVerificationCode(String email); + + /** + * 인증 정보 삭제 + * @param email 인증 정보를 삭제할 이메일 + */ + void deleteVerifiedEmail(String email); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/RedisEmailVerificationCodeStorageCommandService.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/RedisEmailVerificationCodeStorageCommandService.java new file mode 100644 index 0000000..ee7918e --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/RedisEmailVerificationCodeStorageCommandService.java @@ -0,0 +1,33 @@ +package org.withtime.be.withtimebe.domain.auth.service.command; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.withtime.be.withtimebe.domain.auth.constants.EmailVerificationStorageConstants; +import org.withtime.be.withtimebe.global.util.RedisUtil; + +@Service +@RequiredArgsConstructor +public class RedisEmailVerificationCodeStorageCommandService implements EmailVerificationCodeStorageCommandService { + + private final RedisUtil redisUtil; + + @Override + public void saveVerificationCode(String email, String verificationCode) { + redisUtil.set(EmailVerificationStorageConstants.VERIFICATION_CODE_PREFIX + email, verificationCode, EmailVerificationStorageConstants.VERIFICATION_CODE_DURATION); + } + + @Override + public void saveVerifiedEmail(String email) { + redisUtil.set(EmailVerificationStorageConstants.EMAIL_VERIFICATION_PREFIX + email, true, EmailVerificationStorageConstants.EMAIL_VERIFICATION_DURATION); + } + + @Override + public void deleteVerificationCode(String email) { + redisUtil.delete(EmailVerificationStorageConstants.VERIFICATION_CODE_PREFIX + email); + } + + @Override + public void deleteVerifiedEmail(String email) { + redisUtil.delete(EmailVerificationStorageConstants.EMAIL_VERIFICATION_PREFIX + email); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/service/query/EmailVerificationCodeStorageQueryService.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/service/query/EmailVerificationCodeStorageQueryService.java new file mode 100644 index 0000000..32f5386 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/service/query/EmailVerificationCodeStorageQueryService.java @@ -0,0 +1,6 @@ +package org.withtime.be.withtimebe.domain.auth.service.query; + +public interface EmailVerificationCodeStorageQueryService { + boolean checkVerificationCode(String email, String verificationCode); + boolean isVerified(String email); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/service/query/RedisEmailVerificationCodeStorageQueryService.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/service/query/RedisEmailVerificationCodeStorageQueryService.java new file mode 100644 index 0000000..cb5b0b3 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/service/query/RedisEmailVerificationCodeStorageQueryService.java @@ -0,0 +1,23 @@ +package org.withtime.be.withtimebe.domain.auth.service.query; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.withtime.be.withtimebe.domain.auth.constants.EmailVerificationStorageConstants; +import org.withtime.be.withtimebe.global.util.RedisUtil; + +@Service +@RequiredArgsConstructor +public class RedisEmailVerificationCodeStorageQueryService implements EmailVerificationCodeStorageQueryService { + + private final RedisUtil redisUtil; + + @Override + public boolean checkVerificationCode(String email, String verificationCode) { + return verificationCode.equals(redisUtil.get(EmailVerificationStorageConstants.VERIFICATION_CODE_PREFIX + email, String.class)); + } + + @Override + public boolean isVerified(String email) { + return redisUtil.has(EmailVerificationStorageConstants.EMAIL_VERIFICATION_PREFIX + email); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/util/MailVerificationCodeSender.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/util/MailVerificationCodeSender.java new file mode 100644 index 0000000..e970459 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/util/MailVerificationCodeSender.java @@ -0,0 +1,5 @@ +package org.withtime.be.withtimebe.domain.auth.util; + +public interface MailVerificationCodeSender { + void sendMail(String toEmail, String code); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/util/SMTPMailVerificationCodeSender.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/util/SMTPMailVerificationCodeSender.java new file mode 100644 index 0000000..51a2638 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/util/SMTPMailVerificationCodeSender.java @@ -0,0 +1,40 @@ +package org.withtime.be.withtimebe.domain.auth.util; + +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import org.withtime.be.withtimebe.global.error.code.EmailErrorCode; +import org.withtime.be.withtimebe.global.error.exception.EmailException; + +@Component +@RequiredArgsConstructor +public class SMTPMailVerificationCodeSender implements MailVerificationCodeSender { + private final JavaMailSender mailSender; + private final TemplateEngine templateEngine; + + @Override + public void sendMail(String toEmail, String code) { + + // 1. 템플릿 처리 + Context context = new Context(); + context.setVariable("code", code); + String html = templateEngine.process("email-verification", context); + + // 2. 메일 작성 및 전송 + MimeMessage mimeMessage = mailSender.createMimeMessage(); + try { + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, "UTF-8"); + helper.setTo(toEmail); + helper.setSubject("[WithTime] 이메일 인증 코드입니다."); + helper.setText(html, true); // true → HTML 형식 + + mailSender.send(mimeMessage); + } catch (Exception e) { + throw new EmailException(EmailErrorCode.FAIL_EMAIL_SEND); + } + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/code/EmailErrorCode.java b/src/main/java/org/withtime/be/withtimebe/global/error/code/EmailErrorCode.java new file mode 100644 index 0000000..e8f608f --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/error/code/EmailErrorCode.java @@ -0,0 +1,27 @@ +package org.withtime.be.withtimebe.global.error.code; + +import lombok.AllArgsConstructor; +import org.namul.api.payload.code.BaseErrorCode; +import org.namul.api.payload.code.dto.supports.DefaultResponseErrorReasonDTO; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor +public enum EmailErrorCode implements BaseErrorCode { + + FAIL_EMAIL_SEND(HttpStatus.INTERNAL_SERVER_ERROR, "EMAIL500_1", "이메일 전송에 실패했습니다."), + INCORRECT_EMAIL_VERIFICATION_CODE(HttpStatus.UNAUTHORIZED, "EMAIL401_1", "이메일 인증에 실패했습니다."), + UNVERIFIED_EMAIL(HttpStatus.UNAUTHORIZED, "EMAIL401_2", "인증되지 않은 이메일이거나 인증 유효기간이 지났습니다."), + ; + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public DefaultResponseErrorReasonDTO getReason() { + return DefaultResponseErrorReasonDTO.builder() + .httpStatus(this.httpStatus) + .code(this.code) + .message(this.message) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/exception/EmailException.java b/src/main/java/org/withtime/be/withtimebe/global/error/exception/EmailException.java new file mode 100644 index 0000000..2ceeac6 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/error/exception/EmailException.java @@ -0,0 +1,10 @@ +package org.withtime.be.withtimebe.global.error.exception; + +import org.namul.api.payload.code.BaseErrorCode; +import org.namul.api.payload.error.exception.ServerApplicationException; + +public class EmailException extends ServerApplicationException { + public EmailException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/resources/application-develop.yml b/src/main/resources/application-develop.yml index e9cfc7a..353c6f3 100644 --- a/src/main/resources/application-develop.yml +++ b/src/main/resources/application-develop.yml @@ -15,6 +15,17 @@ spring: redis: host: ${REDIS_HOST} port: 6379 + mail: + host: ${MAIL_SENDER_HOST} + port: 587 + username: ${MAIL_SENDER_USERNAME} + password: ${MAIL_SENDER_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true jwt: secret: ${JWT_SECRET} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a8e9cfd..f630364 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -15,6 +15,17 @@ spring: redis: host: ${REDIS_HOST} port: 6379 + mail: + host: ${MAIL_SENDER_HOST} + port: 587 + username: ${MAIL_SENDER_USERNAME} + password: ${MAIL_SENDER_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true jwt: secret: ${JWT_SECRET} diff --git a/src/main/resources/templates/email-verification.html b/src/main/resources/templates/email-verification.html new file mode 100644 index 0000000..eb6c6ff --- /dev/null +++ b/src/main/resources/templates/email-verification.html @@ -0,0 +1,28 @@ + + + + + 이메일 인증 + + + +
+ + +
+ + \ No newline at end of file