Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<EmailVerificationSendResponse> sendEmailVerificationCode(
@Valid @RequestBody EmailVerificationSendRequest request
) {
return ApiResponse.ok(emailVerificationSendService.sendEmailVerificationCode(request));
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
47 changes: 47 additions & 0 deletions src/main/java/ceos/ipx/domain/auth/service/EmailSenderService.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
3 changes: 2 additions & 1 deletion src/main/java/ceos/ipx/global/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ public enum ErrorCode {
REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AU005", "RefreshToken이 존재하지 않습니다."),
INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "AU006", "유효하지 않은 RefreshToken입니다."),
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "AU007", "사용자를 찾을 수 없습니다."),

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", "해당 요청에 권한이 없습니다.");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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/**",
Expand Down
12 changes: 12 additions & 0 deletions src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading