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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,8 @@ out/

### application.properties ###
src/main/resources/application-local.yml
application-local.yml



src/main/generated/
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ dependencies {
// AWS S3 의존성
implementation 'software.amazon.awssdk:s3:2.17.70'
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'


// 메일 전송
implementation 'org.springframework.boot:spring-boot-starter-mail'


}

sourceSets {
Expand Down
28 changes: 28 additions & 0 deletions src/main/java/com/sumte/apiPayload/code/error/EmailErrorCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.sumte.apiPayload.code.error;

import org.springframework.http.HttpStatus;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum EmailErrorCode implements ErrorCode {

// 400 Bad Request
VERIFICATION_CODE_NOT_FOUND("Email404", "인증코드가 만료되었거나 요청되지 않았습니다.", HttpStatus.BAD_REQUEST),
VERIFICATION_CODE_MISMATCH("Email404", "인증코드가 일치하지 않습니다.", HttpStatus.BAD_REQUEST),
MAIL_SENDING_FAILED("Email404", "메일 전송에 실패했습니다.", HttpStatus.BAD_REQUEST),
EMAIL_ALREADY_VERIFIED("Email400", "이미 인증이 완료된 이메일입니다.", HttpStatus.BAD_REQUEST),
RESEND_COOLDOWN("Email429", "재전송 쿨다운 중입니다.", HttpStatus.TOO_MANY_REQUESTS),
INVALID_EMAIL_FORMAT("Email400", "올바르지 않은 이메일 형식입니다.", HttpStatus.BAD_REQUEST);

private final String code;
private final String message;
private final HttpStatus httpStatus;

@Override
public HttpStatus getHttpStatus() {
return httpStatus;
}
}
38 changes: 33 additions & 5 deletions src/main/java/com/sumte/apiPayload/handler/ExceptionAdvice.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package com.sumte.apiPayload.handler;

import jakarta.validation.ConstraintViolationException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
Expand All @@ -17,9 +17,11 @@
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
import com.sumte.apiPayload.ApiResponse;
import com.sumte.apiPayload.code.error.CommonErrorCode;
import com.sumte.apiPayload.code.error.EmailErrorCode;
import com.sumte.apiPayload.code.error.ErrorCode;
import com.sumte.apiPayload.exception.SumteException;

import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;

/**
Expand Down Expand Up @@ -49,11 +51,26 @@ public ResponseEntity<Object> handleIllegalArgument(IllegalArgumentException ex)
public ResponseEntity<Object> handleConstraintViolationException(ConstraintViolationException ex) {
log.warn("handleConstraintViolationException");

//Email 전용 코드로
var first = ex.getConstraintViolations().stream().findFirst();
if (first.isPresent()) {
var v = first.get();
String path = v.getPropertyPath() != null ? v.getPropertyPath().toString() : "";
String msg = v.getMessage();

if (path.toLowerCase().contains("email")) {
return handleExceptionInternal(
EmailErrorCode.INVALID_EMAIL_FORMAT,
msg != null ? msg : EmailErrorCode.INVALID_EMAIL_FORMAT.getMessage()
);
}
}

String message = ex.getConstraintViolations()
.stream()
.findFirst()
.map(violation -> violation.getMessage())
.orElse(CommonErrorCode.INVALID_PARAMETER.getMessage());
.stream()
.findFirst()
.map(violation -> violation.getMessage())
.orElse(CommonErrorCode.INVALID_PARAMETER.getMessage());

return handleExceptionInternal(CommonErrorCode.INVALID_PARAMETER, message);
}
Expand All @@ -71,6 +88,17 @@ public ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotVali
HttpStatusCode status, WebRequest request) {

log.warn("MethodArgumentNotValidException ");

//email 필드 검증 실패시
FieldError fe = e.getBindingResult().getFieldErrors().stream()
.findFirst()
.orElse(null);
if (fe != null && "email".equals(fe.getField())) {
String msg = fe.getDefaultMessage() != null ? fe.getDefaultMessage()
: EmailErrorCode.INVALID_EMAIL_FORMAT.getMessage();
return handleExceptionInternal(EmailErrorCode.INVALID_EMAIL_FORMAT, msg);
}

ErrorCode errorCode = CommonErrorCode.INVALID_PARAMETER;
return handleExceptionInternal(errorCode, getDefaultMessage(e));
}
Expand Down
57 changes: 57 additions & 0 deletions src/main/java/com/sumte/email/Controller/EmailController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.sumte.email.Controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.sumte.email.Service.EmailVerificationService;
import com.sumte.email.dto.request.EmailSendRequest;
import com.sumte.email.dto.request.EmailVerifyRequest;
import com.sumte.email.dto.response.EmailSendResponse;
import com.sumte.email.dto.response.EmailVerifyResponse;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;

@Tag(name = "이메일 인증", description = "이메일 인증번호 발송 및 검증 API")
@RestController
@RequestMapping("/email")
public class EmailController {

private final EmailVerificationService service;

public EmailController(EmailVerificationService service) {
this.service = service;
}

@Operation(
summary = "이메일 인증번호 발송",
description = """
사용자가 입력한 이메일 주소로 인증번호를 발송합니다.
- 이메일 형식 검증이 이루어지고,
- 아래 쿨타임 필드가 보이실텐데 이는 재전송까지 남은 시간을 볼 수 있는 필드로 넣었습니다.
- 쿨다임의 경우 10초로 지정하였고, 쿨타임에 재전송을 하는 경우 에러가 나니 확인해주시면 될 것 같습니다.
"""
)
@PostMapping("/send")
public ResponseEntity<EmailSendResponse> send(@Valid @RequestBody EmailSendRequest req) {
return ResponseEntity.ok(service.sendCode(req.email()));
}

@Operation(
summary = "이메일 인증번호 검증",
description = """
사용자가 입력한 이메일과 인증번호가 일치하는지 확인합니다.
- 요청 시 바디에 이메일도 꼭 포함되도록 했습니다!
- 인증번호가 만료되었거나 요청되지 않은 경우 에러가 발생하고
- 검증 성공 시 바로 인증완료 처리되니 확인해주시면 될 것 같습니다!
"""
)
@PostMapping("/verify")
public ResponseEntity<EmailVerifyResponse> verify(@Valid @RequestBody EmailVerifyRequest req) {
return ResponseEntity.ok(service.verifyCode(req.email(), req.code()));
}
}
24 changes: 24 additions & 0 deletions src/main/java/com/sumte/email/EmailVerificationCleaner.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.sumte.email;

import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import com.sumte.email.store.EmailVerificationStore;

@Component
@EnableScheduling
public class EmailVerificationCleaner {

private final EmailVerificationStore store;

public EmailVerificationCleaner(EmailVerificationStore store) {
this.store = store;
}

// 1분마다 만료된 인증 항목 정리
@Scheduled(fixedDelay = 60_000L)
public void purge() {
store.purgeExpired();
}
}
45 changes: 45 additions & 0 deletions src/main/java/com/sumte/email/EmailVerificationProperties.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.sumte.email;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "app.email-verification")
public class EmailVerificationProperties {
private int codeLength = 6; // 인증코드 자리수
private int ttlSeconds = 600; // 유효기간(초)
private int resendCooldownSeconds = 60; // 재전송 쿨다운(초)
private String from = "no-reply@sumte.com";

public int getCodeLength() {
return codeLength;
}

public void setCodeLength(int codeLength) {
this.codeLength = codeLength;
}

public int getTtlSeconds() {
return ttlSeconds;
}

public void setTtlSeconds(int ttlSeconds) {
this.ttlSeconds = ttlSeconds;
}

public int getResendCooldownSeconds() {
return resendCooldownSeconds;
}

public void setResendCooldownSeconds(int resendCooldownSeconds) {
this.resendCooldownSeconds = resendCooldownSeconds;
}

public String getFrom() {
return from;
}

public void setFrom(String from) {
this.from = from;
}
}
115 changes: 115 additions & 0 deletions src/main/java/com/sumte/email/Service/EmailVerificationService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package com.sumte.email.Service;

import java.time.Duration;
import java.time.Instant;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.mail.MailException;
import org.springframework.stereotype.Service;

import com.sumte.apiPayload.code.error.EmailErrorCode;
import com.sumte.apiPayload.exception.SumteException;
import com.sumte.email.EmailVerificationProperties;
import com.sumte.email.dto.response.EmailSendResponse;
import com.sumte.email.dto.response.EmailVerifyResponse;
import com.sumte.email.sender.EmailSender;
import com.sumte.email.sender.VerificationCodeGenerator;
import com.sumte.email.store.EmailVerificationStore;
import com.sumte.email.store.VerificationEntry;

/**
* 📌 이메일 인증 서비스
*
* - /email/send : 인증번호 생성 & 저장 & 발송
* - /email/verify : 인증번호 검증 (성공 시 일회성으로 폐기)
*
* ⚠️ 실패 시에는 ApiException(EmailErrorCode.*)을 던져서
* GlobalExceptionHandler가 Email4xx 형식의 본문을 내려주게 한다.
* (이렇게 해야 Swagger/프론트에서 COMMON400이 아니라 Email4xx가 보임)
*/
@Service
public class EmailVerificationService {

private static final Logger log = LoggerFactory.getLogger(EmailVerificationService.class);

private final EmailVerificationStore store;
private final VerificationCodeGenerator generator;
private final EmailSender sender;
private final EmailVerificationProperties props;

public EmailVerificationService(EmailVerificationStore store,
VerificationCodeGenerator generator,
EmailSender sender,
EmailVerificationProperties props) {
this.store = store;
this.generator = generator;
this.sender = sender;
this.props = props;
}

public EmailSendResponse sendCode(String email) {
Instant now = Instant.now();
var existing = store.get(email);

// 재전송 쿨다운 적용 (프론트에서 처리안할경우 굳이..)
if (existing != null) {
long elapsed = Duration.between(existing.lastSentAt(), now).getSeconds();
long cooldown = props.getResendCooldownSeconds();
if (elapsed < cooldown) {
long remaining = cooldown - elapsed;
log.debug("Resend cooldown: email={}, remainingSeconds={}", email, remaining);
return new EmailSendResponse(false, "재전송 쿨다운 중입니다.", remaining);
}
}

// 코드 생성 & 저장
String code = generator.numeric(props.getCodeLength());
Instant expiresAt = now.plusSeconds(props.getTtlSeconds());
store.put(email, new VerificationEntry(code, expiresAt, now));

// 메일 양식
String subject = "[숨터] 이메일 인증번호";
String body = """
숨터 이메일 인증번호는 [%s] 입니다.
유효시간: %d분
(잘못 수신한 경우 이 메일을 무시하세요.)
""".formatted(code, props.getTtlSeconds() / 60);

try {
sender.send(email, subject, body);
} catch (MailException e) {
// 메일 전송 실패는 EmailErrorCode.MAIL_SENDING_FAILED 로 통일
log.warn("Mail sending failed: email={}, err={}", email, e.getMessage());
throw new SumteException(EmailErrorCode.MAIL_SENDING_FAILED);
}

log.debug("Email verification code generated: email={}, code={}, expiresAt={}", email, code, expiresAt);
return new EmailSendResponse(true, "인증번호가 발송되었습니다.", 0);
}

public EmailVerifyResponse verifyCode(String email, String code) {
Instant now = Instant.now();
var entry = store.get(email);

// 요청되지 않았거나, 성공을 이미 한 경우
if (entry == null) {
throw new SumteException(EmailErrorCode.VERIFICATION_CODE_NOT_FOUND);
}

// 만료되는경우
if (entry.isExpired(now)) {
store.remove(email);
throw new SumteException(EmailErrorCode.VERIFICATION_CODE_NOT_FOUND);
}

// 일치하지 않는경우
if (!entry.code().equals(code)) {
throw new SumteException(EmailErrorCode.VERIFICATION_CODE_MISMATCH);
}

// 성공 후 바로 삭제
store.remove(email);
return new EmailVerifyResponse(true, "인증이 완료되었습니다.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.sumte.email.dto.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record EmailSendRequest(
@NotBlank @Email String email
) {
}
11 changes: 11 additions & 0 deletions src/main/java/com/sumte/email/dto/request/EmailVerifyRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.sumte.email.dto.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record EmailVerifyRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 4, max = 10) String code
) {
}
Loading