From a3e4ace416025d8cb75a4b96eadddf01eeadb23d Mon Sep 17 00:00:00 2001 From: Oh-Jisong Date: Fri, 3 Jul 2026 06:22:11 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=BD=94=EB=93=9C=20=EB=B0=9C=EC=86=A1=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EmailVerificationController.java | 24 +++++++ .../auth/dto/EmailVerificationPurpose.java | 33 +++++++++ .../dto/EmailVerificationSendRequest.java | 19 +++++ .../dto/EmailVerificationSendResponse.java | 14 ++++ .../auth/service/EmailSenderService.java | 47 ++++++++++++ .../service/EmailVerificationSendService.java | 39 ++++++++++ .../service/EmailVerificationService.java | 72 +++++++++++++++++++ .../ceos/ipx/global/exception/ErrorCode.java | 5 +- .../security/config/SecurityWhitelist.java | 1 + src/main/resources/application-local.yml | 12 ++++ 10 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 src/main/java/ceos/ipx/domain/auth/controller/EmailVerificationController.java create mode 100644 src/main/java/ceos/ipx/domain/auth/dto/EmailVerificationPurpose.java create mode 100644 src/main/java/ceos/ipx/domain/auth/dto/EmailVerificationSendRequest.java create mode 100644 src/main/java/ceos/ipx/domain/auth/dto/EmailVerificationSendResponse.java create mode 100644 src/main/java/ceos/ipx/domain/auth/service/EmailSenderService.java create mode 100644 src/main/java/ceos/ipx/domain/auth/service/EmailVerificationSendService.java create mode 100644 src/main/java/ceos/ipx/domain/auth/service/EmailVerificationService.java diff --git a/src/main/java/ceos/ipx/domain/auth/controller/EmailVerificationController.java b/src/main/java/ceos/ipx/domain/auth/controller/EmailVerificationController.java new file mode 100644 index 0000000..6911357 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/controller/EmailVerificationController.java @@ -0,0 +1,24 @@ +package ceos.ipx.domain.auth.controller; + +import ceos.ipx.domain.auth.dto.EmailVerificationSendRequest; +import ceos.ipx.domain.auth.dto.EmailVerificationSendResponse; +import ceos.ipx.domain.auth.service.EmailVerificationSendService; +import ceos.ipx.global.response.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/auth/email") +public class EmailVerificationController { + + private final EmailVerificationSendService emailVerificationSendService; + + @PostMapping("/send") + public ApiResponse sendEmailVerificationCode( + @Valid @RequestBody EmailVerificationSendRequest request + ) { + return ApiResponse.ok(emailVerificationSendService.sendEmailVerificationCode(request)); + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/dto/EmailVerificationPurpose.java b/src/main/java/ceos/ipx/domain/auth/dto/EmailVerificationPurpose.java new file mode 100644 index 0000000..d9ca76c --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/dto/EmailVerificationPurpose.java @@ -0,0 +1,33 @@ +package ceos.ipx.domain.auth.dto; + +import ceos.ipx.global.exception.BusinessException; +import ceos.ipx.global.exception.ErrorCode; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +@Getter +@RequiredArgsConstructor +public enum EmailVerificationPurpose { + + SIGNUP("signup"), + PASSWORD_RESET("password_reset"); + + private final String value; + + @JsonCreator + public static EmailVerificationPurpose from(String value) { + return Arrays.stream(values()) + .filter(purpose -> purpose.value.equals(value)) + .findFirst() + .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_INPUT_VALUE)); + } + + @JsonValue + public String getValue() { + return value; + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/dto/EmailVerificationSendRequest.java b/src/main/java/ceos/ipx/domain/auth/dto/EmailVerificationSendRequest.java new file mode 100644 index 0000000..a943b04 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/dto/EmailVerificationSendRequest.java @@ -0,0 +1,19 @@ +package ceos.ipx.domain.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class EmailVerificationSendRequest { + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + private String email; + + @NotNull(message = "인증 목적은 필수입니다.") + private EmailVerificationPurpose purpose; +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/dto/EmailVerificationSendResponse.java b/src/main/java/ceos/ipx/domain/auth/dto/EmailVerificationSendResponse.java new file mode 100644 index 0000000..8aef529 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/dto/EmailVerificationSendResponse.java @@ -0,0 +1,14 @@ +package ceos.ipx.domain.auth.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class EmailVerificationSendResponse { + + private String email; + private EmailVerificationPurpose purpose; + private int expiresIn; + private int resendAvailableIn; +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/service/EmailSenderService.java b/src/main/java/ceos/ipx/domain/auth/service/EmailSenderService.java new file mode 100644 index 0000000..5aba240 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/service/EmailSenderService.java @@ -0,0 +1,47 @@ +package ceos.ipx.domain.auth.service; + +import ceos.ipx.global.exception.BusinessException; +import ceos.ipx.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.MailException; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EmailSenderService { + + private final JavaMailSender javaMailSender; + + public void sendVerificationCode(String toEmail, String code) { + try { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(toEmail); + message.setSubject("[IPX] 이메일 인증 코드 안내"); + message.setText(createVerificationCodeMessage(code)); + + javaMailSender.send(message); + + log.info("이메일 인증 코드 발송 완료. email={}", toEmail); + } catch (MailException e) { + log.error("이메일 인증 코드 발송 실패. email={}", toEmail, e); + throw new BusinessException(ErrorCode.EMAIL_SEND_FAILED); + } + } + + private String createVerificationCodeMessage(String code) { + return """ + 안녕하세요. IPX입니다. + + 이메일 인증을 위한 인증 코드는 아래와 같습니다. + + 인증 코드: %s + + 인증 코드는 3분 동안 유효합니다. + 본인이 요청하지 않았다면 이 메일을 무시해주세요. + """.formatted(code); + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/service/EmailVerificationSendService.java b/src/main/java/ceos/ipx/domain/auth/service/EmailVerificationSendService.java new file mode 100644 index 0000000..b30072a --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/service/EmailVerificationSendService.java @@ -0,0 +1,39 @@ +package ceos.ipx.domain.auth.service; + +import ceos.ipx.domain.auth.dto.EmailVerificationPurpose; +import ceos.ipx.domain.auth.dto.EmailVerificationSendRequest; +import ceos.ipx.domain.auth.dto.EmailVerificationSendResponse; +import ceos.ipx.domain.user.repository.UserRepository; +import ceos.ipx.global.exception.BusinessException; +import ceos.ipx.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class EmailVerificationSendService { + + private final UserRepository userRepository; + private final EmailVerificationService emailVerificationService; + private final EmailSenderService emailSenderService; + + public EmailVerificationSendResponse sendEmailVerificationCode(EmailVerificationSendRequest request) { + String email = request.getEmail(); + EmailVerificationPurpose purpose = request.getPurpose(); + + if (purpose == EmailVerificationPurpose.SIGNUP && userRepository.existsByEmail(email)) { + throw new BusinessException(ErrorCode.EMAIL_ALREADY_EXISTS); + } + + String verificationCode = emailVerificationService.createAndSaveVerificationCode(purpose, email); + + emailSenderService.sendVerificationCode(email, verificationCode); + + return EmailVerificationSendResponse.builder() + .email(email) + .purpose(purpose) + .expiresIn(emailVerificationService.getCodeExpiresInSeconds()) + .resendAvailableIn(emailVerificationService.getResendAvailableInSeconds()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/service/EmailVerificationService.java b/src/main/java/ceos/ipx/domain/auth/service/EmailVerificationService.java new file mode 100644 index 0000000..0653f6f --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/service/EmailVerificationService.java @@ -0,0 +1,72 @@ +package ceos.ipx.domain.auth.service; + +import ceos.ipx.domain.auth.dto.EmailVerificationPurpose; +import ceos.ipx.global.exception.BusinessException; +import ceos.ipx.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class EmailVerificationService { + + private static final int CODE_LENGTH = 6; + private static final int CODE_EXPIRES_IN_SECONDS = 180; + private static final int RESEND_AVAILABLE_IN_SECONDS = 60; + + private static final String CODE_KEY_PREFIX = "emailVerification:code:"; + private static final String COOLDOWN_KEY_PREFIX = "emailVerification:cooldown:"; + + private final StringRedisTemplate stringRedisTemplate; + + public String createAndSaveVerificationCode(EmailVerificationPurpose purpose, String email) { + String cooldownKey = createCooldownKey(purpose, email); + + Boolean hasCooldown = stringRedisTemplate.hasKey(cooldownKey); + if (Boolean.TRUE.equals(hasCooldown)) { + throw new BusinessException(ErrorCode.EMAIL_VERIFICATION_RESEND_TOO_EARLY); + } + + String code = generateCode(); + + stringRedisTemplate.opsForValue().set( + createCodeKey(purpose, email), + code, + Duration.ofSeconds(CODE_EXPIRES_IN_SECONDS) + ); + + stringRedisTemplate.opsForValue().set( + cooldownKey, + "1", + Duration.ofSeconds(RESEND_AVAILABLE_IN_SECONDS) + ); + + return code; + } + + public int getCodeExpiresInSeconds() { + return CODE_EXPIRES_IN_SECONDS; + } + + public int getResendAvailableInSeconds() { + return RESEND_AVAILABLE_IN_SECONDS; + } + + private String generateCode() { + SecureRandom random = new SecureRandom(); + int number = random.nextInt(1_000_000); + return String.format("%06d", number); + } + + private String createCodeKey(EmailVerificationPurpose purpose, String email) { + return CODE_KEY_PREFIX + purpose.getValue() + ":" + email; + } + + private String createCooldownKey(EmailVerificationPurpose purpose, String email) { + return COOLDOWN_KEY_PREFIX + purpose.getValue() + ":" + email; + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/global/exception/ErrorCode.java b/src/main/java/ceos/ipx/global/exception/ErrorCode.java index b5d98b4..ea288b4 100644 --- a/src/main/java/ceos/ipx/global/exception/ErrorCode.java +++ b/src/main/java/ceos/ipx/global/exception/ErrorCode.java @@ -7,11 +7,14 @@ @Getter @RequiredArgsConstructor public enum ErrorCode { + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "잘못된 입력값입니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C002", "서버 내부 오류가 발생했습니다."), EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "AU001", "이미 사용 중인 이메일입니다."), PASSWORD_CONFIRM_MISMATCH(HttpStatus.BAD_REQUEST, "AU002", "비밀번호와 비밀번호 확인이 일치하지 않습니다."), + EMAIL_VERIFICATION_RESEND_TOO_EARLY(HttpStatus.TOO_MANY_REQUESTS, "AU008", "인증 코드는 잠시 후 다시 요청할 수 있습니다."), + EMAIL_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AU009", "이메일 발송에 실패했습니다."), UNAUTHORIZED_USER(HttpStatus.UNAUTHORIZED, "SC001", "인증이 필요합니다."), ACCESS_DENIED(HttpStatus.FORBIDDEN, "SC002", "해당 요청에 권한이 없습니다."); @@ -19,4 +22,4 @@ public enum ErrorCode { private final HttpStatus status; private final String code; private final String message; -} +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/global/security/config/SecurityWhitelist.java b/src/main/java/ceos/ipx/global/security/config/SecurityWhitelist.java index b539772..750c2ae 100644 --- a/src/main/java/ceos/ipx/global/security/config/SecurityWhitelist.java +++ b/src/main/java/ceos/ipx/global/security/config/SecurityWhitelist.java @@ -20,6 +20,7 @@ private SecurityWhitelist() { "/api/auth/email/verify-otp", // 이메일 OTP 인증 "/api/auth/password/reset", // 비밀번호 재설정 요청 "/api/auth/password/reset/verify", // 비밀번호 재설정 확인 + "/api/auth/email/send", // OAuth 콜백 (Google 로그인) "/oauth2/**", diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index c133397..b912a63 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -17,6 +17,18 @@ spring: host: localhost port: 6379 + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME:} + password: ${MAIL_PASSWORD:} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + logging: level: root: INFO