From d78c0e1c3d8528847818e0abd7a832a1156a75a7 Mon Sep 17 00:00:00 2001 From: hodoon Date: Sun, 8 Feb 2026 14:20:26 -0500 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20DI=EA=B3=A8=EA=B2=A9=20=EB=B0=8F=20Port/Pr?= =?UTF-8?q?operties=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 6 +++- .../com/dreamteam/alter/AlterApplication.java | 3 ++ .../SendEmailVerificationCodeRequestDto.java | 16 +++++++++++ ...VerifyEmailVerificationCodeRequestDto.java | 19 +++++++++++++ .../email/properties/EmailAuthProperties.java | 15 ++++++++++ .../service/VerificationCodeGenerator.java | 16 +++++++++++ .../usecase/SendEmailVerificationCode.java | 28 +++++++++++++++++++ .../usecase/VerifyEmailVerificationCode.java | 23 +++++++++++++++ .../SendEmailVerificationCodeUseCase.java | 7 +++++ .../VerifyEmailVerificationCodeUseCase.java | 7 +++++ .../email/port/outbound/EmailSenderPort.java | 5 ++++ .../EmailVerificationTokenStorePort.java | 16 +++++++++++ 12 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/SendEmailVerificationCodeRequestDto.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/VerifyEmailVerificationCodeRequestDto.java create mode 100644 src/main/java/com/dreamteam/alter/application/email/properties/EmailAuthProperties.java create mode 100644 src/main/java/com/dreamteam/alter/application/email/service/VerificationCodeGenerator.java create mode 100644 src/main/java/com/dreamteam/alter/application/email/usecase/SendEmailVerificationCode.java create mode 100644 src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java create mode 100644 src/main/java/com/dreamteam/alter/domain/email/port/inbound/SendEmailVerificationCodeUseCase.java create mode 100644 src/main/java/com/dreamteam/alter/domain/email/port/inbound/VerifyEmailVerificationCodeUseCase.java create mode 100644 src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailSenderPort.java create mode 100644 src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailVerificationTokenStorePort.java diff --git a/build.gradle b/build.gradle index efc1b752..5b7b6918 100644 --- a/build.gradle +++ b/build.gradle @@ -46,6 +46,9 @@ dependencies { exclude group: 'io.swagger.core.v3', module: 'swagger-annotations' } + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'software.amazon.awssdk:ses:2.29.46' + compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' @@ -62,8 +65,9 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + runtimeOnly 'org.postgresql:postgresql' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } dependencyManagement { diff --git a/src/main/java/com/dreamteam/alter/AlterApplication.java b/src/main/java/com/dreamteam/alter/AlterApplication.java index 419357bd..096f735e 100644 --- a/src/main/java/com/dreamteam/alter/AlterApplication.java +++ b/src/main/java/com/dreamteam/alter/AlterApplication.java @@ -1,13 +1,16 @@ package com.dreamteam.alter; +import com.dreamteam.alter.application.email.properties.EmailAuthProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.retry.annotation.EnableRetry; @SpringBootApplication @EnableJpaAuditing @EnableRetry +@EnableConfigurationProperties({EmailAuthProperties.class}) public class AlterApplication { public static void main(String[] args) { diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/SendEmailVerificationCodeRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/SendEmailVerificationCodeRequestDto.java new file mode 100644 index 00000000..e919280b --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/SendEmailVerificationCodeRequestDto.java @@ -0,0 +1,16 @@ +package com.dreamteam.alter.adapter.inbound.general.amail.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "이메일 인증 코드 발송 요청") +public class SendEmailVerificationCodeRequestDto { + + @Schema(description = "인증할 이메일 주소", example = "user@example.com") + private String email; +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/VerifyEmailVerificationCodeRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/VerifyEmailVerificationCodeRequestDto.java new file mode 100644 index 00000000..8a24c0ce --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/VerifyEmailVerificationCodeRequestDto.java @@ -0,0 +1,19 @@ +package com.dreamteam.alter.adapter.inbound.general.amail.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "이메일 인증 코드 검증 요청") +public class VerifyEmailVerificationCodeRequestDto { + + @Schema(description = "인증할 이메일 주소", example = "user@example.com") + private String email; + + @Schema(description = "수신한 인증 코드 6자리", example = "123456") + private String code; +} diff --git a/src/main/java/com/dreamteam/alter/application/email/properties/EmailAuthProperties.java b/src/main/java/com/dreamteam/alter/application/email/properties/EmailAuthProperties.java new file mode 100644 index 00000000..b4eff4d3 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/email/properties/EmailAuthProperties.java @@ -0,0 +1,15 @@ +package com.dreamteam.alter.application.email.properties; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "alter.email") +public class EmailAuthProperties { + private String from; + private long codeTtlSeconds = 300; + private long verifiedTtlSeconds = 900; + private long cooldownSeconds = 30; +} diff --git a/src/main/java/com/dreamteam/alter/application/email/service/VerificationCodeGenerator.java b/src/main/java/com/dreamteam/alter/application/email/service/VerificationCodeGenerator.java new file mode 100644 index 00000000..eb8471db --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/email/service/VerificationCodeGenerator.java @@ -0,0 +1,16 @@ +package com.dreamteam.alter.application.email.service; + +import org.springframework.stereotype.Component; + +import java.security.SecureRandom; + +@Component +public class VerificationCodeGenerator { + + private static final SecureRandom RANDOM = new SecureRandom(); + + public String generate() { + int code = 100000 + RANDOM.nextInt(900000); + return String.valueOf(code); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/email/usecase/SendEmailVerificationCode.java b/src/main/java/com/dreamteam/alter/application/email/usecase/SendEmailVerificationCode.java new file mode 100644 index 00000000..36b496a8 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/email/usecase/SendEmailVerificationCode.java @@ -0,0 +1,28 @@ +package com.dreamteam.alter.application.email.usecase; + +import com.dreamteam.alter.adapter.inbound.general.amail.dto.SendEmailVerificationCodeRequestDto; +import com.dreamteam.alter.application.email.properties.EmailAuthProperties; +import com.dreamteam.alter.application.email.service.VerificationCodeGenerator; +import com.dreamteam.alter.domain.email.port.inbound.SendEmailVerificationCodeUseCase; +import com.dreamteam.alter.domain.email.port.outbound.EmailSenderPort; +import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationTokenStorePort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("sendEmailVerificationCode") +@RequiredArgsConstructor +@Transactional +public class SendEmailVerificationCode implements SendEmailVerificationCodeUseCase { + + private final EmailVerificationTokenStorePort tokenStorePort; + private final EmailSenderPort emailSenderPort; + private final VerificationCodeGenerator codeGenerator; + private final EmailAuthProperties properties; + + + @Override + public void execute(SendEmailVerificationCodeRequestDto request) { + + } +} diff --git a/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java b/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java new file mode 100644 index 00000000..c8742817 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java @@ -0,0 +1,23 @@ +package com.dreamteam.alter.application.email.usecase; + +import com.dreamteam.alter.adapter.inbound.general.amail.dto.VerifyEmailVerificationCodeRequestDto; +import com.dreamteam.alter.application.email.properties.EmailAuthProperties; +import com.dreamteam.alter.domain.email.port.inbound.VerifyEmailVerificationCodeUseCase; +import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationTokenStorePort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("verifyEmailVerificationCode") +@RequiredArgsConstructor +@Transactional +public class VerifyEmailVerificationCode implements VerifyEmailVerificationCodeUseCase { + + private final EmailVerificationTokenStorePort tokenStorePort; + private final EmailAuthProperties properties; + + @Override + public void execute(VerifyEmailVerificationCodeRequestDto request) { + + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/email/port/inbound/SendEmailVerificationCodeUseCase.java b/src/main/java/com/dreamteam/alter/domain/email/port/inbound/SendEmailVerificationCodeUseCase.java new file mode 100644 index 00000000..962b0589 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/email/port/inbound/SendEmailVerificationCodeUseCase.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.email.port.inbound; + +import com.dreamteam.alter.adapter.inbound.general.amail.dto.SendEmailVerificationCodeRequestDto; + +public interface SendEmailVerificationCodeUseCase { + void execute(SendEmailVerificationCodeRequestDto request); +} diff --git a/src/main/java/com/dreamteam/alter/domain/email/port/inbound/VerifyEmailVerificationCodeUseCase.java b/src/main/java/com/dreamteam/alter/domain/email/port/inbound/VerifyEmailVerificationCodeUseCase.java new file mode 100644 index 00000000..d23c815c --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/email/port/inbound/VerifyEmailVerificationCodeUseCase.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.email.port.inbound; + +import com.dreamteam.alter.adapter.inbound.general.amail.dto.VerifyEmailVerificationCodeRequestDto; + +public interface VerifyEmailVerificationCodeUseCase { + void execute(VerifyEmailVerificationCodeRequestDto request); +} diff --git a/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailSenderPort.java b/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailSenderPort.java new file mode 100644 index 00000000..53b5e27a --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailSenderPort.java @@ -0,0 +1,5 @@ +package com.dreamteam.alter.domain.email.port.outbound; + +public interface EmailSenderPort { + void sendVerificationCode(String toEmail, String code); +} diff --git a/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailVerificationTokenStorePort.java b/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailVerificationTokenStorePort.java new file mode 100644 index 00000000..f560dc8d --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailVerificationTokenStorePort.java @@ -0,0 +1,16 @@ +package com.dreamteam.alter.domain.email.port.outbound; + +import java.time.Duration; +import java.util.Optional; + +public interface EmailVerificationTokenStorePort { + void saveCode(String email, String code, Duration ttl); + Optional findCode(String email); + void deleteCode(String email); + + void markVerified(String email, Duration ttl); + boolean isVerified(String email); + + boolean isCooldown(String email); + void markCooldown(String email, Duration ttl); +} From fa96eaa7288b761c63af785f965709150554a434 Mon Sep 17 00:00:00 2001 From: hodoon Date: Sun, 8 Feb 2026 14:30:01 -0500 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EB=B9=84=EC=A6=88=EB=8B=88=EC=8A=A4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20ErrorCode?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/SendEmailVerificationCode.java | 21 +++++++++++++++++++ .../usecase/VerifyEmailVerificationCode.java | 18 ++++++++++++++++ .../alter/common/exception/ErrorCode.java | 6 ++++++ 3 files changed, 45 insertions(+) diff --git a/src/main/java/com/dreamteam/alter/application/email/usecase/SendEmailVerificationCode.java b/src/main/java/com/dreamteam/alter/application/email/usecase/SendEmailVerificationCode.java index 36b496a8..a36563e8 100644 --- a/src/main/java/com/dreamteam/alter/application/email/usecase/SendEmailVerificationCode.java +++ b/src/main/java/com/dreamteam/alter/application/email/usecase/SendEmailVerificationCode.java @@ -3,6 +3,8 @@ import com.dreamteam.alter.adapter.inbound.general.amail.dto.SendEmailVerificationCodeRequestDto; import com.dreamteam.alter.application.email.properties.EmailAuthProperties; import com.dreamteam.alter.application.email.service.VerificationCodeGenerator; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.domain.email.port.inbound.SendEmailVerificationCodeUseCase; import com.dreamteam.alter.domain.email.port.outbound.EmailSenderPort; import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationTokenStorePort; @@ -10,6 +12,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; + @Service("sendEmailVerificationCode") @RequiredArgsConstructor @Transactional @@ -23,6 +27,23 @@ public class SendEmailVerificationCode implements SendEmailVerificationCodeUseCa @Override public void execute(SendEmailVerificationCodeRequestDto request) { + String email = request.getEmail(); + + // Check Cooldown + if (tokenStorePort.isCooldown(email)) { + throw new CustomException(ErrorCode.EMAIL_VERIFICATION_TOO_MANY_REQUESTS); + } + + // Generate Code + String code = codeGenerator.generate(); + + // Save Code (TTL) + tokenStorePort.saveCode(email, code, Duration.ofSeconds(properties.getCodeTtlSeconds())); + + // Mark Cooldown + tokenStorePort.markCooldown(email,Duration.ofSeconds(properties.getCooldownSeconds())); + // Send Email + emailSenderPort.sendVerificationCode(email, code); } } diff --git a/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java b/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java index c8742817..1a6b11a3 100644 --- a/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java +++ b/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java @@ -2,12 +2,16 @@ import com.dreamteam.alter.adapter.inbound.general.amail.dto.VerifyEmailVerificationCodeRequestDto; import com.dreamteam.alter.application.email.properties.EmailAuthProperties; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.domain.email.port.inbound.VerifyEmailVerificationCodeUseCase; import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationTokenStorePort; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; + @Service("verifyEmailVerificationCode") @RequiredArgsConstructor @Transactional @@ -18,6 +22,20 @@ public class VerifyEmailVerificationCode implements VerifyEmailVerificationCodeU @Override public void execute(VerifyEmailVerificationCodeRequestDto request) { + String email = request.getEmail(); + String inputCode = request.getCode(); + + // Find Code + String storedCode = tokenStorePort.findCode(email) + .orElseThrow(() -> new CustomException(ErrorCode.EMAIL_VERIFICATION_CODE_EXPIRED)); + + // Compare + if (!storedCode.equals(inputCode)) { + throw new CustomException(ErrorCode.EMAIL_VERIFICATION_CODE_MISMATCH); + } + // Success -> Delete Code & Mark Verified + tokenStorePort.deleteCode(email); + tokenStorePort.markVerified(email, Duration.ofSeconds(properties.getVerifiedTtlSeconds())); } } diff --git a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java index 4d37eccc..d56a5e1e 100644 --- a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java +++ b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java @@ -42,6 +42,12 @@ public enum ErrorCode { WORKSPACE_WORKER_ALREADY_EXISTS(400, "B018", "이미 근무중인 사용자입니다."), NOT_FOUND(404, "B019", "요청한 리소스를 찾을 수 없습니다."), CONFLICT(409, "B020", "변경할 수 없는 상태입니다."), + + EMAIL_VERIFICATION_CODE_EXPIRED(400, "E001", "인증 코드가 없거나 만료되었습니다."), + EMAIL_VERIFICATION_CODE_MISMATCH(400, "E002", "인증 코드가 일치하지 않습니다."), + EMAIL_VERIFICATION_SEND_FAILED(500, "E003", "이메일 전송에 실패했습니다."), + EMAIL_VERIFICATION_TOO_MANY_REQUESTS(429, "E004", "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), + INTERNAL_SERVER_ERROR(400, "C001", "서버 내부 오류입니다."), EXTERNAL_API_ERROR(502, "C002", "외부 API 연동에 실패했습니다."), ; From 12b1af8c7cc571f90573a927f82be631ad3c0d1a Mon Sep 17 00:00:00 2001 From: hodoon Date: Sun, 8 Feb 2026 14:36:35 -0500 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20Redis=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=9D=B8=EC=A6=9D=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...disEmailVerificationTokenStoreAdapter.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/redis/email/RedisEmailVerificationTokenStoreAdapter.java diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/redis/email/RedisEmailVerificationTokenStoreAdapter.java b/src/main/java/com/dreamteam/alter/adapter/outbound/redis/email/RedisEmailVerificationTokenStoreAdapter.java new file mode 100644 index 00000000..07cf2309 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/redis/email/RedisEmailVerificationTokenStoreAdapter.java @@ -0,0 +1,56 @@ +package com.dreamteam.alter.adapter.outbound.redis.email; + +import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationTokenStorePort; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class RedisEmailVerificationTokenStoreAdapter implements EmailVerificationTokenStorePort { + + private final StringRedisTemplate redisTemplate; + + private static final String KEY_PREFIX_CODE = "auth:email:code:"; + private static final String KEY_PREFIX_VERIFIED = "auth:email:verified:"; + private static final String KEY_PREFIX_COOLDOWN = "auth:email:cooldown:"; + + @Override + public void saveCode(String email, String code, Duration ttl) { + redisTemplate.opsForValue().set(KEY_PREFIX_CODE + email, code, ttl); + } + + @Override + public Optional findCode(String email) { + String code = redisTemplate.opsForValue().get(KEY_PREFIX_CODE + email); + return Optional.ofNullable(code); + } + + @Override + public void deleteCode(String email) { + redisTemplate.delete(KEY_PREFIX_CODE + email); + } + + @Override + public void markVerified(String email, Duration ttl) { + redisTemplate.opsForValue().set(KEY_PREFIX_VERIFIED + email, "true", ttl); + } + + @Override + public boolean isVerified(String email) { + return redisTemplate.hasKey(KEY_PREFIX_VERIFIED + email); + } + + @Override + public boolean isCooldown(String email) { + return redisTemplate.hasKey(KEY_PREFIX_COOLDOWN + email); + } + + @Override + public void markCooldown(String email, Duration ttl) { + redisTemplate.opsForValue().set(KEY_PREFIX_COOLDOWN + email, "true", ttl); + } +} From 223b08b2142d245bef6fbbb3bdfdbdde47dddcf9 Mon Sep 17 00:00:00 2001 From: hodoon Date: Sun, 8 Feb 2026 14:48:00 -0500 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20AWS=20SES=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EB=B0=9C=EC=86=A1=20=EC=96=B4=EB=8C=91=ED=84=B0=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/dreamteam/alter/AlterApplication.java | 3 +- .../aws/ses/SesEmailSenderAdapter.java | 50 +++++++++++++++++++ .../outbound/aws/ses/config/AwsSesConfig.java | 22 ++++++++ .../aws/ses/properties/AwsProperties.java | 12 +++++ 4 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/SesEmailSenderAdapter.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/config/AwsSesConfig.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/properties/AwsProperties.java diff --git a/src/main/java/com/dreamteam/alter/AlterApplication.java b/src/main/java/com/dreamteam/alter/AlterApplication.java index 096f735e..eaf429b6 100644 --- a/src/main/java/com/dreamteam/alter/AlterApplication.java +++ b/src/main/java/com/dreamteam/alter/AlterApplication.java @@ -1,5 +1,6 @@ package com.dreamteam.alter; +import com.dreamteam.alter.adapter.outbound.aws.ses.properties.AwsProperties; import com.dreamteam.alter.application.email.properties.EmailAuthProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -10,7 +11,7 @@ @SpringBootApplication @EnableJpaAuditing @EnableRetry -@EnableConfigurationProperties({EmailAuthProperties.class}) +@EnableConfigurationProperties({EmailAuthProperties.class, AwsProperties.class}) public class AlterApplication { public static void main(String[] args) { diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/SesEmailSenderAdapter.java b/src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/SesEmailSenderAdapter.java new file mode 100644 index 00000000..6e3ee531 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/SesEmailSenderAdapter.java @@ -0,0 +1,50 @@ +package com.dreamteam.alter.adapter.outbound.aws.ses; + +import com.dreamteam.alter.application.email.properties.EmailAuthProperties; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.email.port.outbound.EmailSenderPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.ses.SesClient; +import software.amazon.awssdk.services.ses.model.*; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SesEmailSenderAdapter implements EmailSenderPort { + + private final SesClient sesClient; + private final EmailAuthProperties emailProperties; + + @Override + public void sendVerificationCode(String toEmail, String code) { + try { + String subject = "[ALTER] 이메일 인증 코드"; + String bodyText = "인증 코드: " + code + "\n\n이 코드는 5분간 유효합니다."; + + SendEmailRequest request = SendEmailRequest.builder() + .source(emailProperties.getFrom()) + .destination(Destination.builder().toAddresses(toEmail).build()) + .message(Message.builder() + .subject(Content.builder().data(subject).build()) + .body(Body.builder() + .text(Content.builder().data(bodyText).build()) + .build()) + .build()) + .build(); + + sesClient.sendEmail(request); + log.info("Sent verification email to: {}", toEmail); + + } catch (SesException e) { + log.error("Failed to send SES email to {}: {}", toEmail, e.awsErrorDetails().errorMessage()); + throw new CustomException(ErrorCode.EMAIL_VERIFICATION_SEND_FAILED); + } catch (Exception e) { + log.error("Unexpected error sending email to {}: {}",toEmail, e.getMessage()); + throw new CustomException(ErrorCode.EMAIL_VERIFICATION_SEND_FAILED); + } + + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/config/AwsSesConfig.java b/src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/config/AwsSesConfig.java new file mode 100644 index 00000000..5f188355 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/config/AwsSesConfig.java @@ -0,0 +1,22 @@ +package com.dreamteam.alter.adapter.outbound.aws.ses.config; + +import com.dreamteam.alter.adapter.outbound.aws.ses.properties.AwsProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.ses.SesClient; + +@Configuration +@RequiredArgsConstructor +public class AwsSesConfig { + + private final AwsProperties awsProperties; + + @Bean + public SesClient sesClient() { + return SesClient.builder() + .region(Region.of(awsProperties.getRegion())) + .build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/properties/AwsProperties.java b/src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/properties/AwsProperties.java new file mode 100644 index 00000000..ed0aa623 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/properties/AwsProperties.java @@ -0,0 +1,12 @@ +package com.dreamteam.alter.adapter.outbound.aws.ses.properties; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "aws") +public class AwsProperties { + private String region; +} From ea2261fd9ce54e6ae97515af7d7491d3328af4a7 Mon Sep 17 00:00:00 2001 From: hodoon Date: Sun, 8 Feb 2026 14:56:44 -0500 Subject: [PATCH 05/12] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20API=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC?= =?UTF-8?q?=20=EB=B0=8F=20DTO=20=EA=B2=80=EC=A6=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EmailVerificationController.java | 43 +++++++++++++++++++ .../SendEmailVerificationCodeRequestDto.java | 4 ++ ...VerifyEmailVerificationCodeRequestDto.java | 7 +++ 3 files changed, 54 insertions(+) create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/controller/EmailVerificationController.java diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/controller/EmailVerificationController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/controller/EmailVerificationController.java new file mode 100644 index 00000000..3f2058f0 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/controller/EmailVerificationController.java @@ -0,0 +1,43 @@ +package com.dreamteam.alter.adapter.inbound.general.amail.controller; + +import com.dreamteam.alter.adapter.inbound.general.amail.dto.SendEmailVerificationCodeRequestDto; +import com.dreamteam.alter.adapter.inbound.general.amail.dto.VerifyEmailVerificationCodeRequestDto; +import com.dreamteam.alter.domain.email.port.inbound.SendEmailVerificationCodeUseCase; +import com.dreamteam.alter.domain.email.port.inbound.VerifyEmailVerificationCodeUseCase; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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; + +@Tag(name = "Auth", description = "인증 관련 API") +@RestController +@RequestMapping("/auth/email") +@RequiredArgsConstructor +public class EmailVerificationController { + + @Resource(name = "sendEmailVerificationCode") + private final SendEmailVerificationCodeUseCase sendEmailVerificationCode; + + @Resource(name = "verifyEmailVerificationCode") + private final VerifyEmailVerificationCodeUseCase verifyEmailVerificationCode; + + @Operation(summary = "이메일 인증 코드 발송", description = "이메일로 6자리 인증 코드를 발송") + @PostMapping("/send") + public ResponseEntity sendVerificationCode(@RequestBody @Valid SendEmailVerificationCodeRequestDto request) { + sendEmailVerificationCode.execute(request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "이메일 인증 코드 검증", description = "발송된 인증 코드를 검증합니다.") + @PostMapping("/verify") + public ResponseEntity verifyVerificationCode(@RequestBody @Valid VerifyEmailVerificationCodeRequestDto request) { + verifyEmailVerificationCode.execute(request); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/SendEmailVerificationCodeRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/SendEmailVerificationCodeRequestDto.java index e919280b..60c56bbb 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/SendEmailVerificationCodeRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/SendEmailVerificationCodeRequestDto.java @@ -1,6 +1,8 @@ package com.dreamteam.alter.adapter.inbound.general.amail.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -11,6 +13,8 @@ @Schema(description = "이메일 인증 코드 발송 요청") public class SendEmailVerificationCodeRequestDto { + @NotBlank + @Email @Schema(description = "인증할 이메일 주소", example = "user@example.com") private String email; } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/VerifyEmailVerificationCodeRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/VerifyEmailVerificationCodeRequestDto.java index 8a24c0ce..ac12c2e0 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/VerifyEmailVerificationCodeRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/VerifyEmailVerificationCodeRequestDto.java @@ -1,6 +1,9 @@ package com.dreamteam.alter.adapter.inbound.general.amail.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -11,9 +14,13 @@ @Schema(description = "이메일 인증 코드 검증 요청") public class VerifyEmailVerificationCodeRequestDto { + @NotBlank + @Email @Schema(description = "인증할 이메일 주소", example = "user@example.com") private String email; + @NotBlank + @Pattern(regexp = "^[0-9]{6}$", message = "인증 코드는 6자리 숫자여야 합니다.") @Schema(description = "수신한 인증 코드 6자리", example = "123456") private String code; } From 60e4f132ab235c654dd113ffb4758def18d6fa8c Mon Sep 17 00:00:00 2001 From: hodoon Date: Sun, 8 Feb 2026 14:59:22 -0500 Subject: [PATCH 06/12] =?UTF-8?q?config:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20AWS=20SES=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 54b48e47..cdb7e893 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -55,10 +55,17 @@ alter: exposed-headers: ${EXPOSED_HEADERS} allowed-pattern: ${ALLOWED_PATTERN} permit-all-urls: ${PERMIT_ALL_URLS} + email: + from: ${SES_FROM_EMAIL} + code-ttl-seconds: ${EMAIL_CODE_TTL_SECONDS:300} + verified-ttl-seconds: ${EMAIL_VERIFIED_TTL_SECONDS:900} + cooldown-seconds: ${EMAIL_COOLDOWN_SECONDS:30} firebase: fcm: project-id: ${FIREBASE_PROJECT_ID} service-account-key: ${FIREBASE_SERVICE_ACCOUNT_KEY} sgis: service-id: ${SGIS_SERVICE_ID} - service-secret: ${SGIS_SERVICE_SECRET} \ No newline at end of file + service-secret: ${SGIS_SERVICE_SECRET} +aws: + region: ${AWS_REGION} \ No newline at end of file From b97e601471ab744ab863d00420210df8bdbbf929 Mon Sep 17 00:00:00 2001 From: hodoon Date: Sun, 8 Feb 2026 17:10:27 -0500 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20UserPublicController=EC=97=90=20?= =?UTF-8?q?=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=9D=B8=EC=A6=9D=20API=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EmailVerificationController.java | 43 ------------------- .../SendEmailVerificationCodeRequestDto.java | 2 +- ...VerifyEmailVerificationCodeRequestDto.java | 2 +- .../user/controller/UserPublicController.java | 29 +++++++++++++ .../controller/UserPublicControllerSpec.java | 19 ++++++++ .../usecase/SendEmailVerificationCode.java | 2 +- .../usecase/VerifyEmailVerificationCode.java | 2 +- .../SendEmailVerificationCodeUseCase.java | 2 +- .../VerifyEmailVerificationCodeUseCase.java | 2 +- 9 files changed, 54 insertions(+), 49 deletions(-) delete mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/controller/EmailVerificationController.java rename src/main/java/com/dreamteam/alter/adapter/inbound/general/{amail => email}/dto/SendEmailVerificationCodeRequestDto.java (89%) rename src/main/java/com/dreamteam/alter/adapter/inbound/general/{amail => email}/dto/VerifyEmailVerificationCodeRequestDto.java (92%) diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/controller/EmailVerificationController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/controller/EmailVerificationController.java deleted file mode 100644 index 3f2058f0..00000000 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/controller/EmailVerificationController.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.dreamteam.alter.adapter.inbound.general.amail.controller; - -import com.dreamteam.alter.adapter.inbound.general.amail.dto.SendEmailVerificationCodeRequestDto; -import com.dreamteam.alter.adapter.inbound.general.amail.dto.VerifyEmailVerificationCodeRequestDto; -import com.dreamteam.alter.domain.email.port.inbound.SendEmailVerificationCodeUseCase; -import com.dreamteam.alter.domain.email.port.inbound.VerifyEmailVerificationCodeUseCase; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.annotation.Resource; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -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; - -@Tag(name = "Auth", description = "인증 관련 API") -@RestController -@RequestMapping("/auth/email") -@RequiredArgsConstructor -public class EmailVerificationController { - - @Resource(name = "sendEmailVerificationCode") - private final SendEmailVerificationCodeUseCase sendEmailVerificationCode; - - @Resource(name = "verifyEmailVerificationCode") - private final VerifyEmailVerificationCodeUseCase verifyEmailVerificationCode; - - @Operation(summary = "이메일 인증 코드 발송", description = "이메일로 6자리 인증 코드를 발송") - @PostMapping("/send") - public ResponseEntity sendVerificationCode(@RequestBody @Valid SendEmailVerificationCodeRequestDto request) { - sendEmailVerificationCode.execute(request); - return ResponseEntity.ok().build(); - } - - @Operation(summary = "이메일 인증 코드 검증", description = "발송된 인증 코드를 검증합니다.") - @PostMapping("/verify") - public ResponseEntity verifyVerificationCode(@RequestBody @Valid VerifyEmailVerificationCodeRequestDto request) { - verifyEmailVerificationCode.execute(request); - return ResponseEntity.ok().build(); - } -} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/SendEmailVerificationCodeRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/SendEmailVerificationCodeRequestDto.java similarity index 89% rename from src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/SendEmailVerificationCodeRequestDto.java rename to src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/SendEmailVerificationCodeRequestDto.java index 60c56bbb..b86b064a 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/SendEmailVerificationCodeRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/SendEmailVerificationCodeRequestDto.java @@ -1,4 +1,4 @@ -package com.dreamteam.alter.adapter.inbound.general.amail.dto; +package com.dreamteam.alter.adapter.inbound.general.email.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/VerifyEmailVerificationCodeRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/VerifyEmailVerificationCodeRequestDto.java similarity index 92% rename from src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/VerifyEmailVerificationCodeRequestDto.java rename to src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/VerifyEmailVerificationCodeRequestDto.java index ac12c2e0..864f6d30 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/amail/dto/VerifyEmailVerificationCodeRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/VerifyEmailVerificationCodeRequestDto.java @@ -1,4 +1,4 @@ -package com.dreamteam.alter.adapter.inbound.general.amail.dto; +package com.dreamteam.alter.adapter.inbound.general.email.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicController.java index ebdba828..51d24796 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicController.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicController.java @@ -1,7 +1,11 @@ package com.dreamteam.alter.adapter.inbound.general.user.controller; import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; +import com.dreamteam.alter.adapter.inbound.general.email.dto.SendEmailVerificationCodeRequestDto; +import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeRequestDto; import com.dreamteam.alter.adapter.inbound.general.user.dto.*; +import com.dreamteam.alter.domain.email.port.inbound.SendEmailVerificationCodeUseCase; +import com.dreamteam.alter.domain.email.port.inbound.VerifyEmailVerificationCodeUseCase; import com.dreamteam.alter.domain.user.port.inbound.CreateSignupSessionUseCase; import com.dreamteam.alter.domain.user.port.inbound.LoginWithPasswordUseCase; import com.dreamteam.alter.domain.user.port.inbound.LoginWithSocialUseCase; @@ -55,6 +59,13 @@ public class UserPublicController implements UserPublicControllerSpec { @Resource(name = "resetPassword") private final ResetPasswordUseCase resetPassword; + @Resource(name = "sendEmailVerificationCode") + private final SendEmailVerificationCodeUseCase sendEmailVerificationCode; + + @Resource(name = "verifyEmailVerificationCode") + private final VerifyEmailVerificationCodeUseCase verifyEmailVerificationCode; + + @Override @PostMapping("/signup-session") public ResponseEntity> createSignupSession( @@ -135,4 +146,22 @@ public ResponseEntity> resetPassword( resetPassword.execute(request); return ResponseEntity.ok(CommonApiResponse.empty()); } + + @Override + @PostMapping("/email/send") + public ResponseEntity> sendVerificationCode( + @Valid @RequestBody SendEmailVerificationCodeRequestDto request + ) { + sendEmailVerificationCode.execute(request); + return ResponseEntity.ok(CommonApiResponse.empty()); + } + + @Override + @PostMapping("/email/verify") + public ResponseEntity> verifyVerificationCode( + @Valid @RequestBody VerifyEmailVerificationCodeRequestDto request + ) { + verifyEmailVerificationCode.execute(request); + return ResponseEntity.ok(CommonApiResponse.empty()); + } } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java index 702cc984..eb8b2aa2 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java @@ -2,6 +2,8 @@ import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; import com.dreamteam.alter.adapter.inbound.common.dto.ErrorResponse; +import com.dreamteam.alter.adapter.inbound.general.email.dto.SendEmailVerificationCodeRequestDto; +import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeRequestDto; import com.dreamteam.alter.adapter.inbound.general.user.dto.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -215,4 +217,21 @@ public interface UserPublicControllerSpec { }) ResponseEntity> resetPassword(@Valid ResetPasswordRequestDto request); + @Operation(summary = "이메일 인증 코드 발송", description = "이메일로 6자리 인증 코드 발송") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "인증 코드 발송 성공"), + @ApiResponse(responseCode = "429", description = "요청이 너무 많음 (쿨다운)", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + }) + ResponseEntity> sendVerificationCode(@Valid SendEmailVerificationCodeRequestDto request); + + @Operation(summary = "이메일 인증 코드 검증", description = "발송된 인증 코드를 검증") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "인증 코드 검증 성공"), + @ApiResponse(responseCode = "400", description = "인증 코드 불일치 또는 만료", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + }) + ResponseEntity> verifyVerificationCode(@Valid VerifyEmailVerificationCodeRequestDto request); + + } diff --git a/src/main/java/com/dreamteam/alter/application/email/usecase/SendEmailVerificationCode.java b/src/main/java/com/dreamteam/alter/application/email/usecase/SendEmailVerificationCode.java index a36563e8..948858d2 100644 --- a/src/main/java/com/dreamteam/alter/application/email/usecase/SendEmailVerificationCode.java +++ b/src/main/java/com/dreamteam/alter/application/email/usecase/SendEmailVerificationCode.java @@ -1,6 +1,6 @@ package com.dreamteam.alter.application.email.usecase; -import com.dreamteam.alter.adapter.inbound.general.amail.dto.SendEmailVerificationCodeRequestDto; +import com.dreamteam.alter.adapter.inbound.general.email.dto.SendEmailVerificationCodeRequestDto; import com.dreamteam.alter.application.email.properties.EmailAuthProperties; import com.dreamteam.alter.application.email.service.VerificationCodeGenerator; import com.dreamteam.alter.common.exception.CustomException; diff --git a/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java b/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java index 1a6b11a3..a4f35c7e 100644 --- a/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java +++ b/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java @@ -1,6 +1,6 @@ package com.dreamteam.alter.application.email.usecase; -import com.dreamteam.alter.adapter.inbound.general.amail.dto.VerifyEmailVerificationCodeRequestDto; +import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeRequestDto; import com.dreamteam.alter.application.email.properties.EmailAuthProperties; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; diff --git a/src/main/java/com/dreamteam/alter/domain/email/port/inbound/SendEmailVerificationCodeUseCase.java b/src/main/java/com/dreamteam/alter/domain/email/port/inbound/SendEmailVerificationCodeUseCase.java index 962b0589..d6eb848a 100644 --- a/src/main/java/com/dreamteam/alter/domain/email/port/inbound/SendEmailVerificationCodeUseCase.java +++ b/src/main/java/com/dreamteam/alter/domain/email/port/inbound/SendEmailVerificationCodeUseCase.java @@ -1,6 +1,6 @@ package com.dreamteam.alter.domain.email.port.inbound; -import com.dreamteam.alter.adapter.inbound.general.amail.dto.SendEmailVerificationCodeRequestDto; +import com.dreamteam.alter.adapter.inbound.general.email.dto.SendEmailVerificationCodeRequestDto; public interface SendEmailVerificationCodeUseCase { void execute(SendEmailVerificationCodeRequestDto request); diff --git a/src/main/java/com/dreamteam/alter/domain/email/port/inbound/VerifyEmailVerificationCodeUseCase.java b/src/main/java/com/dreamteam/alter/domain/email/port/inbound/VerifyEmailVerificationCodeUseCase.java index d23c815c..3c7a4a2b 100644 --- a/src/main/java/com/dreamteam/alter/domain/email/port/inbound/VerifyEmailVerificationCodeUseCase.java +++ b/src/main/java/com/dreamteam/alter/domain/email/port/inbound/VerifyEmailVerificationCodeUseCase.java @@ -1,6 +1,6 @@ package com.dreamteam.alter.domain.email.port.inbound; -import com.dreamteam.alter.adapter.inbound.general.amail.dto.VerifyEmailVerificationCodeRequestDto; +import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeRequestDto; public interface VerifyEmailVerificationCodeUseCase { void execute(VerifyEmailVerificationCodeRequestDto request); From a86306fcdd95d5f22cfe62f8d8e7ae17f0bb6bf7 Mon Sep 17 00:00:00 2001 From: hodoon Date: Sun, 8 Feb 2026 18:42:20 -0500 Subject: [PATCH 08/12] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=8B=9C=EB=8F=84=ED=9A=9F=EC=88=98=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/UserPublicControllerSpec.java | 58 +++++++++++++++++-- ...disEmailVerificationTokenStoreAdapter.java | 14 +++++ .../email/properties/EmailAuthProperties.java | 1 + .../usecase/VerifyEmailVerificationCode.java | 8 +++ .../alter/common/exception/ErrorCode.java | 1 + .../EmailVerificationTokenStorePort.java | 2 + src/main/resources/application.yml | 1 + 7 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java index eb8b2aa2..abbeccf2 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java @@ -217,19 +217,65 @@ public interface UserPublicControllerSpec { }) ResponseEntity> resetPassword(@Valid ResetPasswordRequestDto request); - @Operation(summary = "이메일 인증 코드 발송", description = "이메일로 6자리 인증 코드 발송") + @Operation( + summary = "이메일 인증 코드 발송", + description = "이메일로 6자리 인증 코드 발송" + ) @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "인증 코드 발송 성공"), + @ApiResponse( + responseCode = "200", + description = "인증 코드 발송 성공" + ), @ApiResponse(responseCode = "429", description = "요청이 너무 많음 (쿨다운)", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "쿨다운 위반", + value = "{\"success\": false, \"code\" : \"E004\", \"message\" : \"요청이 너무 많습니다. 잠시 후 다시 시도해주세요.\"}" + ) + } + ) + ), + @ApiResponse(responseCode = "500", description = "서버 에러", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "이메일 전송 실패", + value = "{\"success\": false, \"code\" : \"E003\", \"message\" : \"이메일 전송에 실패했습니다.\"}" + ) + } + ) + ) }) ResponseEntity> sendVerificationCode(@Valid SendEmailVerificationCodeRequestDto request); - @Operation(summary = "이메일 인증 코드 검증", description = "발송된 인증 코드를 검증") + @Operation(summary = "이메일 인증 코드 검증", description = "발송된 인증 코드를 검증합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "인증 코드 검증 성공"), - @ApiResponse(responseCode = "400", description = "인증 코드 불일치 또는 만료", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "인증 코드 만료/없음", + value = "{\"success\": false, \"code\" : \"E001\", \"message\" : \"인증 코드가 없거나 만료되었습니다.\"}" + ), + @ExampleObject( + name = "인증 코드 불일치", + value = "{\"success\": false, \"code\" : \"E002\", \"message\" : \"인증 코드가 일치하지 않습니다.\"}" + ), + @ExampleObject( + name = "인증 시도 횟수 초과", + value = "{\"success\": false, \"code\" : \"E005\", \"message\" : \"인증 시도 횟수를 초과했습니다. 코드를 다시 발송해주세요.\"}" + ) + } + ) + ) }) ResponseEntity> verifyVerificationCode(@Valid VerifyEmailVerificationCodeRequestDto request); diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/redis/email/RedisEmailVerificationTokenStoreAdapter.java b/src/main/java/com/dreamteam/alter/adapter/outbound/redis/email/RedisEmailVerificationTokenStoreAdapter.java index 07cf2309..96e39f01 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/redis/email/RedisEmailVerificationTokenStoreAdapter.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/redis/email/RedisEmailVerificationTokenStoreAdapter.java @@ -17,9 +17,12 @@ public class RedisEmailVerificationTokenStoreAdapter implements EmailVerificatio private static final String KEY_PREFIX_CODE = "auth:email:code:"; private static final String KEY_PREFIX_VERIFIED = "auth:email:verified:"; private static final String KEY_PREFIX_COOLDOWN = "auth:email:cooldown:"; + private static final String KEY_PREFIX_ATTEMPTS = "auth:email:attempts:"; + @Override public void saveCode(String email, String code, Duration ttl) { + redisTemplate.delete(KEY_PREFIX_ATTEMPTS + email); redisTemplate.opsForValue().set(KEY_PREFIX_CODE + email, code, ttl); } @@ -53,4 +56,15 @@ public boolean isCooldown(String email) { public void markCooldown(String email, Duration ttl) { redisTemplate.opsForValue().set(KEY_PREFIX_COOLDOWN + email, "true", ttl); } + + @Override + public long incrementAttempt(String email, Duration ttl) { + String key = KEY_PREFIX_ATTEMPTS + email; + Long attempts = redisTemplate.opsForValue().increment(key); + if (attempts != null && attempts == 1) { + // 처음 생성된 키라면 TTL 설정 (코드 TTL과 맞추거나 별도 설정) + redisTemplate.expire(key, ttl); + } + return attempts != null ? attempts : 1L; + } } diff --git a/src/main/java/com/dreamteam/alter/application/email/properties/EmailAuthProperties.java b/src/main/java/com/dreamteam/alter/application/email/properties/EmailAuthProperties.java index b4eff4d3..48ecf503 100644 --- a/src/main/java/com/dreamteam/alter/application/email/properties/EmailAuthProperties.java +++ b/src/main/java/com/dreamteam/alter/application/email/properties/EmailAuthProperties.java @@ -12,4 +12,5 @@ public class EmailAuthProperties { private long codeTtlSeconds = 300; private long verifiedTtlSeconds = 900; private long cooldownSeconds = 30; + private int maxAttempts = 5; } diff --git a/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java b/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java index a4f35c7e..cd32f28e 100644 --- a/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java +++ b/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java @@ -31,6 +31,14 @@ public void execute(VerifyEmailVerificationCodeRequestDto request) { // Compare if (!storedCode.equals(inputCode)) { + // 시도 횟수 증가 + long attempts = tokenStorePort.incrementAttempt(email, Duration.ofSeconds(properties.getCodeTtlSeconds())); + + if (attempts >= properties.getMaxAttempts()) { + tokenStorePort.deleteCode(email); + throw new CustomException(ErrorCode.EMAIL_VERIFICATION_EXCEEDED_MAX_ATTEMPTS); + } + throw new CustomException(ErrorCode.EMAIL_VERIFICATION_CODE_MISMATCH); } diff --git a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java index d56a5e1e..81f75308 100644 --- a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java +++ b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java @@ -47,6 +47,7 @@ public enum ErrorCode { EMAIL_VERIFICATION_CODE_MISMATCH(400, "E002", "인증 코드가 일치하지 않습니다."), EMAIL_VERIFICATION_SEND_FAILED(500, "E003", "이메일 전송에 실패했습니다."), EMAIL_VERIFICATION_TOO_MANY_REQUESTS(429, "E004", "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), + EMAIL_VERIFICATION_EXCEEDED_MAX_ATTEMPTS(400, "E005", "인증 시도 횟수를 초과했습니다."), INTERNAL_SERVER_ERROR(400, "C001", "서버 내부 오류입니다."), EXTERNAL_API_ERROR(502, "C002", "외부 API 연동에 실패했습니다."), diff --git a/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailVerificationTokenStorePort.java b/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailVerificationTokenStorePort.java index f560dc8d..04771d24 100644 --- a/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailVerificationTokenStorePort.java +++ b/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailVerificationTokenStorePort.java @@ -13,4 +13,6 @@ public interface EmailVerificationTokenStorePort { boolean isCooldown(String email); void markCooldown(String email, Duration ttl); + + long incrementAttempt(String email, Duration ttl); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index cdb7e893..32a705a7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -60,6 +60,7 @@ alter: code-ttl-seconds: ${EMAIL_CODE_TTL_SECONDS:300} verified-ttl-seconds: ${EMAIL_VERIFIED_TTL_SECONDS:900} cooldown-seconds: ${EMAIL_COOLDOWN_SECONDS:30} + max-attempts: ${EMAIL_MAX_ATTEMPTS:5} firebase: fcm: project-id: ${FIREBASE_PROJECT_ID} From 997b46a3a42762a644b3a30510173e24507264f1 Mon Sep 17 00:00:00 2001 From: hodoon Date: Sun, 8 Feb 2026 18:45:45 -0500 Subject: [PATCH 09/12] =?UTF-8?q?config:=20test=20application.yml=EC=97=90?= =?UTF-8?q?=20mock=EA=B0=92=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 3910362a..c5b98820 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -49,6 +49,12 @@ alter: exposed-headers: "*" allowed-pattern: "*" permit-all-urls: "/**" + email: + from: mock@mock.com + code-ttl-seconds: 300 + verified-ttl-seconds: 900 + cooldown-seconds: 30 + max-attempts: 5 firebase: fcm: service-account-key: 'ewogICJ0eXBlIjogInNlcnZpY2VfYWNjb3VudCIsCiAgInByb2plY3RfaWQiOiAiZHVtbXktZHVtbXktZHVtbXkiLAogICJwcml2YXRlX2tleV9pZCI6ICJzYWZoYXNlaWZoZXNpZm5lc2FuaWZ1Z2h3aXJ1Zmhpd2FuZmp3a2RzIiwKICAicHJpdmF0ZV9rZXkiOiAiLS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2QUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktZd2dnU2lBZ0VBQW9JQkFRRENJblhCQ2RjdnJrMHQKMEx6N0orOU9MZ25CdWJJUEdGVXNocjFWd29hdFZZZHgwMWduUjJQVllHVVowMHREOW5lRzl6d1VWbEh6U3ZzeQpzd09wZHBCaFV3MEtRaUo4TGJvMFYyMGtjbGNaZk1ENFZMUERoWHE2L1VuMkZ1T1dTK1lwb1hidHJqRzlSTmtkCjA1cS9MYUdoK1FoRHVpS3h1KzdNYjcyTGdoUU51K3pHRFlFSE16NTZBSC9qZ211VUd3aVM5bURKV1J4NDRiSXUKNDlXcDROK2NKb1NNYU1qYXEzUE9rREVyMGJpei9tc09wK1FUTmtIeG5aWXRHbVQ2anpBRTBIMkZrQVY1U0sveQpFdlFrWElENUN0NUUzaHR4NGFSWDIyY2RXcU9IazJtU2VxaGdZRUswc3VwbW5FOWZ1U3lxMHVKNXJTQTgrc1JvClBKS0xiY2ZUQWdNQkFBRUNnZ0VBQUp6VEEwemgvMk9vRVRTbjI0WGhncEo2bHBXdU9rdlFualBwZmRzRHVnMjQKa2JXcmRYZUxWd2NqQ2d6TzFkdGlnS2cxTjNaTm1rclVRNlBqakRTcXZXUWZwMnp6ZkFjcDBoN3FvQ1V5R2E0NwpQQ090QXkyV0IxVFFWNTJmRmpMYlU1ckNIa3pEdE5jazZjTlJYNDNTb0ZiSG8yMUJTVmo5QjVuSkpwZENhZkM4CmF0eTdLd240eHpqVUFIWE9HK3crb1V3WmVET3Y1UGd6dVlld09FTDhrZ2NnWWxSRzZSVnJIME14RWZuRkltdmEKS2FzemVrNHhUTWFnL1hDSDZ0bFNvU3ZTcnRJS1BzL3VUaDBRaW5uKzJQTHRWYWR0djFIaTltalk4VDJoV0l5Two2N3M2MUJzTzdQbU1oU3FyUXZuaSsrK21JcTl0TVVBZ2czc092RVFuUFFLQmdRRGhqUVQxYy9TdGlmSHBnd0ZLCmFIMytOWG9wbGdPMW12SnZyQjREM0U2dENFR1c0M1haQlpobmlncU1xN3F2OUlGa1I3RDd5NmV6ekFic2trYUkKUWxORTlsVCtuOW1ZaFdGK0xiRU5tSXJ1WlVhR1hwLzdBeWhXZDlBZmZUcmFrOWh2RVJscmdNeG1uSW9SckhyNApjZU8yYk9scEtmclVyc1BpMVRBazhaZDRWUUtCZ1FEY1Y3UDVmUU9pMzFPSjRvMnFwcElyN0lpMjVLQ1dkcFVhCmNUR0duYkc2Vll5dGxwTTF1SDdyMXZsVWdXQWp6UkRCdjRTaUxqRzdvYXdiNmFJSk1FLytzNWZ4Q1lxM3Z5L2cKNUlxVUhudU04YmFadzMvbEJsbVpic1N0bXVCYmpUdzA4bDNtYktzc0RDc2ZUU3IwMHhlRFp4OTFhTEpISGNWYwpyN3RxampjSGh3S0JnRzlqQWhqcGUrTWI1YkVKTm1EMXU0c0lBOTEzclR0Sld3TFZRRGx0MmhqUG8veU5Oa3pICjI1eithZmxRY2JDbGtpVGcxc0Z5c000MUt4STNwc2R4NGNlRDB6T3Y0M2pVSGZKL1JCblB4SVM0MVJ4VXJMTDgKdXpZQWszS0ptTUFMRlc2OFJnNTJHL3Rzd1M2N1BEdG5teW9qSFI0SFVrMG9SYXJHMTdEVzhwUEZBb0dBREM1UgpCYjdTZjZPRzg3MXhoWGlWNWhXNmJSbndnc0RsZDBQQXNDZHhsdEo1NTNMR2lwYTdkWUE1NG1FUWxvb1VuaEZmClhMUGZEZmRmRTEvMEZEdjJnQ2NmaERTNTFYU2RTZnA0YXIzUXFMY0lHRElGbFB5bjRXS05QdWVyOVlPMlMxc0cKcytGWUNTUlhFZkRyS2dPdGJoYzZWdnhGdHNhL2pXTXRvak5nZVdzQ2dZQnlLUCtuUlNwS1psYVNXVmF0SkV1ZAp5eHF1M3VMQUFBRWNPYUJUV05nVGliTnlyR1ByMmVVelRPd3ZtVkpjdlA5NlZlTEk5QlFjVlJwYmkwY1Vxb0x1ClhSZjZZbC81WEhVb1RvNHVqRUUyeTdwVjFLWGFZdVlURXkrZ3hVQmQyVzVNYTVyR3p5MFFnYzEvd0VLcEtweDQKclZmVWg5TVpycTdjRjl2VkI3a1BTZz09Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0iLAogICJjbGllbnRfZW1haWwiOiAiZmFzZW9pZmpuZXdub2lmZXNmamFzaGZqaWRzamZlc2FqaGZ1YW5zZGtmZWZpZmRrLmlhbS5nc2VydmljZWFjY291bnQuY29tIiwKICAiY2xpZW50X2lkIjogIjEyMzQ1Njc4OTEyMzQ1Njc4OSIsCiAgImF1dGhfdXJpIjogImh0dHBzOi8vYWNjb3VudHMuZ29vZ2xlLmNvbS9vL29hdXRoMi9hdXRoIiwKICAidG9rZW5fdXJpIjogImh0dHBzOi8vb2F1dGgyLmdvb2dsZWFwaXMuY29tL3Rva2VuIiwKICAiYXV0aF9wcm92aWRlcl94NTA5X2NlcnRfdXJsIjogImh0dHBzOi8vd3d3Lmdvb2dsZWFwaXMuY29tL29hdXRoMi92MS9jZXJ0cyIsCiAgImNsaWVudF94NTA5X2NlcnRfdXJsIjogImh0dHBzOi8vd3d3Lmdvb2dsZWFwaXMuY29tL3JvYm90L3YxL21ldGFkYXRhL3g1MDkvYXNvZWlmam93YWVqZmxrYWpzZm9pamV3YW9pZmpldy5pYW0uZ3NlcnZpY2VhY2NvdW50LmNvbSIsCiAgInVuaXZlcnNlX2RvbWFpbiI6ICJnb29nbGVhcGlzLmNvbSIKfQ==' @@ -57,3 +63,5 @@ firebase: sgis: service-id: mock-sgis-service-id service-secret: mock-sgis-service-secret +aws: + region: mockocko From fd56900eb831926da6f86763dd15896707320f94 Mon Sep 17 00:00:00 2001 From: hodoon Date: Sun, 8 Feb 2026 20:31:11 -0500 Subject: [PATCH 10/12] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=BC=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/dreamteam/alter/AlterApplication.java | 2 + .../EmailSendLogJpaRepository.java | 7 +++ .../EmailSendLogRepositoryImpl.java | 25 +++++++++ .../readonly/EmailSendLogResponse.java | 14 +++++ ...disEmailVerificationTokenStoreAdapter.java | 2 +- .../email/event/EmailSendEvent.java | 10 ++++ .../email/event/EmailSendEventListener.java | 40 ++++++++++++++ .../usecase/SendEmailVerificationCode.java | 24 +++++++-- .../domain/email/entity/EmailSendLog.java | 53 +++++++++++++++++++ .../email/port/outbound/EmailSendLogPort.java | 10 ++++ .../domain/email/type/EmailSendStatus.java | 7 +++ 11 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/email/persistence/EmailSendLogJpaRepository.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/email/persistence/EmailSendLogRepositoryImpl.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/email/persistence/readonly/EmailSendLogResponse.java rename src/main/java/com/dreamteam/alter/adapter/outbound/{redis/email => email/redis}/RedisEmailVerificationTokenStoreAdapter.java (97%) create mode 100644 src/main/java/com/dreamteam/alter/application/email/event/EmailSendEvent.java create mode 100644 src/main/java/com/dreamteam/alter/application/email/event/EmailSendEventListener.java create mode 100644 src/main/java/com/dreamteam/alter/domain/email/entity/EmailSendLog.java create mode 100644 src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailSendLogPort.java create mode 100644 src/main/java/com/dreamteam/alter/domain/email/type/EmailSendStatus.java diff --git a/src/main/java/com/dreamteam/alter/AlterApplication.java b/src/main/java/com/dreamteam/alter/AlterApplication.java index eaf429b6..651ea894 100644 --- a/src/main/java/com/dreamteam/alter/AlterApplication.java +++ b/src/main/java/com/dreamteam/alter/AlterApplication.java @@ -7,10 +7,12 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.retry.annotation.EnableRetry; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication @EnableJpaAuditing @EnableRetry +@EnableAsync @EnableConfigurationProperties({EmailAuthProperties.class, AwsProperties.class}) public class AlterApplication { diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/email/persistence/EmailSendLogJpaRepository.java b/src/main/java/com/dreamteam/alter/adapter/outbound/email/persistence/EmailSendLogJpaRepository.java new file mode 100644 index 00000000..08d171e4 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/email/persistence/EmailSendLogJpaRepository.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.adapter.outbound.email.persistence; + +import com.dreamteam.alter.domain.email.entity.EmailSendLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EmailSendLogJpaRepository extends JpaRepository { +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/email/persistence/EmailSendLogRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/email/persistence/EmailSendLogRepositoryImpl.java new file mode 100644 index 00000000..e9eeaeea --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/email/persistence/EmailSendLogRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.dreamteam.alter.adapter.outbound.email.persistence; + +import com.dreamteam.alter.domain.email.entity.EmailSendLog; +import com.dreamteam.alter.domain.email.port.outbound.EmailSendLogPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class EmailSendLogRepositoryImpl implements EmailSendLogPort { + + private final EmailSendLogJpaRepository jpaRepository; + + @Override + public EmailSendLog save(EmailSendLog log) { + return jpaRepository.save(log); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/email/persistence/readonly/EmailSendLogResponse.java b/src/main/java/com/dreamteam/alter/adapter/outbound/email/persistence/readonly/EmailSendLogResponse.java new file mode 100644 index 00000000..d4e17adb --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/email/persistence/readonly/EmailSendLogResponse.java @@ -0,0 +1,14 @@ +package com.dreamteam.alter.adapter.outbound.email.persistence.readonly; + +import com.dreamteam.alter.domain.email.type.EmailSendStatus; + +import java.time.LocalDateTime; + +public record EmailSendLogResponse( + Long id, + String email, + String code, + EmailSendStatus status, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/redis/email/RedisEmailVerificationTokenStoreAdapter.java b/src/main/java/com/dreamteam/alter/adapter/outbound/email/redis/RedisEmailVerificationTokenStoreAdapter.java similarity index 97% rename from src/main/java/com/dreamteam/alter/adapter/outbound/redis/email/RedisEmailVerificationTokenStoreAdapter.java rename to src/main/java/com/dreamteam/alter/adapter/outbound/email/redis/RedisEmailVerificationTokenStoreAdapter.java index 96e39f01..4ab5eabf 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/redis/email/RedisEmailVerificationTokenStoreAdapter.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/email/redis/RedisEmailVerificationTokenStoreAdapter.java @@ -1,4 +1,4 @@ -package com.dreamteam.alter.adapter.outbound.redis.email; +package com.dreamteam.alter.adapter.outbound.email.redis; import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationTokenStorePort; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/dreamteam/alter/application/email/event/EmailSendEvent.java b/src/main/java/com/dreamteam/alter/application/email/event/EmailSendEvent.java new file mode 100644 index 00000000..99dec59a --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/email/event/EmailSendEvent.java @@ -0,0 +1,10 @@ +package com.dreamteam.alter.application.email.event; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class EmailSendEvent { + private Long logId; +} diff --git a/src/main/java/com/dreamteam/alter/application/email/event/EmailSendEventListener.java b/src/main/java/com/dreamteam/alter/application/email/event/EmailSendEventListener.java new file mode 100644 index 00000000..1b7f5ce4 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/email/event/EmailSendEventListener.java @@ -0,0 +1,40 @@ +package com.dreamteam.alter.application.email.event; + +import com.dreamteam.alter.domain.email.port.outbound.EmailSendLogPort; +import com.dreamteam.alter.domain.email.port.outbound.EmailSenderPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class EmailSendEventListener { + + private final EmailSendLogPort emailSendLogPort; + private final EmailSenderPort emailSenderPort; + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleEmailSendEvent(EmailSendEvent event) { + Long logId = event.getLogId(); + + emailSendLogPort.findById(logId).ifPresent(logItem -> { + try { + log.info("Async sending email to: {}", logItem.getEmail()); + emailSenderPort.sendVerificationCode(logItem.getEmail(), logItem.getCode()); + logItem.markSent(); + } catch (Exception e) { + log.error("Async failed to send email to: {}", logItem.getEmail(), e); + logItem.markFailed(); + } + emailSendLogPort.save(logItem); + }); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/email/usecase/SendEmailVerificationCode.java b/src/main/java/com/dreamteam/alter/application/email/usecase/SendEmailVerificationCode.java index 948858d2..b1ac9847 100644 --- a/src/main/java/com/dreamteam/alter/application/email/usecase/SendEmailVerificationCode.java +++ b/src/main/java/com/dreamteam/alter/application/email/usecase/SendEmailVerificationCode.java @@ -1,18 +1,23 @@ package com.dreamteam.alter.application.email.usecase; import com.dreamteam.alter.adapter.inbound.general.email.dto.SendEmailVerificationCodeRequestDto; +import com.dreamteam.alter.application.email.event.EmailSendEvent; import com.dreamteam.alter.application.email.properties.EmailAuthProperties; import com.dreamteam.alter.application.email.service.VerificationCodeGenerator; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.domain.email.port.inbound.SendEmailVerificationCodeUseCase; -import com.dreamteam.alter.domain.email.port.outbound.EmailSenderPort; +import com.dreamteam.alter.domain.email.entity.EmailSendLog; +import com.dreamteam.alter.domain.email.type.EmailSendStatus; +import com.dreamteam.alter.domain.email.port.outbound.EmailSendLogPort; import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationTokenStorePort; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; +import java.time.LocalDateTime; @Service("sendEmailVerificationCode") @RequiredArgsConstructor @@ -20,9 +25,10 @@ public class SendEmailVerificationCode implements SendEmailVerificationCodeUseCase { private final EmailVerificationTokenStorePort tokenStorePort; - private final EmailSenderPort emailSenderPort; + private final EmailSendLogPort emailSendLogPort; private final VerificationCodeGenerator codeGenerator; private final EmailAuthProperties properties; + private final ApplicationEventPublisher eventPublisher; @Override @@ -41,9 +47,19 @@ public void execute(SendEmailVerificationCodeRequestDto request) { tokenStorePort.saveCode(email, code, Duration.ofSeconds(properties.getCodeTtlSeconds())); // Mark Cooldown - tokenStorePort.markCooldown(email,Duration.ofSeconds(properties.getCooldownSeconds())); + tokenStorePort.markCooldown(email, Duration.ofSeconds(properties.getCooldownSeconds())); + + // Save to DB for batch Sending (Not Sending immediately) + EmailSendLog log = EmailSendLog.builder() + .email(email) + .code(code) + .status(EmailSendStatus.PENDING) + .createdAt(LocalDateTime.now()) + .build(); // Send Email - emailSenderPort.sendVerificationCode(email, code); + EmailSendLog saved = emailSendLogPort.save(log); + + eventPublisher.publishEvent(new EmailSendEvent(saved.getId())); } } diff --git a/src/main/java/com/dreamteam/alter/domain/email/entity/EmailSendLog.java b/src/main/java/com/dreamteam/alter/domain/email/entity/EmailSendLog.java new file mode 100644 index 00000000..d3ea5350 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/email/entity/EmailSendLog.java @@ -0,0 +1,53 @@ +package com.dreamteam.alter.domain.email.entity; + +import com.dreamteam.alter.domain.email.type.EmailSendStatus; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "email_send_log") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class EmailSendLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "email", nullable = false, length = 100) + private String email; + + @Column(name = "code", nullable = false, length = 6) + private String code; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private EmailSendStatus status; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "sent_at") + private LocalDateTime sentAt; + + public void markSent() { + this.status = EmailSendStatus.SENT; + this.sentAt = LocalDateTime.now(); + } + + public void markFailed() { + this.status = EmailSendStatus.FAILED; + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailSendLogPort.java b/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailSendLogPort.java new file mode 100644 index 00000000..5f53315c --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailSendLogPort.java @@ -0,0 +1,10 @@ +package com.dreamteam.alter.domain.email.port.outbound; + +import com.dreamteam.alter.domain.email.entity.EmailSendLog; + +import java.util.Optional; + +public interface EmailSendLogPort { + EmailSendLog save(EmailSendLog log); + Optional findById(Long id); +} diff --git a/src/main/java/com/dreamteam/alter/domain/email/type/EmailSendStatus.java b/src/main/java/com/dreamteam/alter/domain/email/type/EmailSendStatus.java new file mode 100644 index 00000000..e42b8a9b --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/email/type/EmailSendStatus.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.email.type; + +public enum EmailSendStatus { + PENDING, + SENT, + FAILED +} From 4e642c1c4f4b4e890c702ebedaac153a3c9750cb Mon Sep 17 00:00:00 2001 From: hodoon Date: Tue, 10 Feb 2026 18:42:25 -0500 Subject: [PATCH 11/12] =?UTF-8?q?config:=20AWS=ED=82=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../outbound/aws/ses/config/AwsSesConfig.java | 19 ++++++++++++++++--- .../aws/ses/properties/AwsProperties.java | 2 ++ src/main/resources/application.yml | 4 +++- src/test/resources/application.yml | 2 ++ 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/config/AwsSesConfig.java b/src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/config/AwsSesConfig.java index 5f188355..fbd68ebd 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/config/AwsSesConfig.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/config/AwsSesConfig.java @@ -4,8 +4,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.ses.SesClient; +import software.amazon.awssdk.services.ses.SesClientBuilder; @Configuration @RequiredArgsConstructor @@ -15,8 +19,17 @@ public class AwsSesConfig { @Bean public SesClient sesClient() { - return SesClient.builder() - .region(Region.of(awsProperties.getRegion())) - .build(); + SesClientBuilder builder = SesClient.builder() + .region(Region.of(awsProperties.getRegion())); + + if (StringUtils.hasText(awsProperties.getAccessKey()) && StringUtils.hasText(awsProperties.getSecretKey())) { + AwsBasicCredentials credentials = AwsBasicCredentials.create( + awsProperties.getAccessKey(), + awsProperties.getSecretKey() + ); + builder.credentialsProvider(StaticCredentialsProvider.create(credentials)); + } + + return builder.build(); } } diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/properties/AwsProperties.java b/src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/properties/AwsProperties.java index ed0aa623..63b291f4 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/properties/AwsProperties.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/properties/AwsProperties.java @@ -9,4 +9,6 @@ @ConfigurationProperties(prefix = "aws") public class AwsProperties { private String region; + private String accessKey; + private String secretKey; } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 32a705a7..a370c2c3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -69,4 +69,6 @@ sgis: service-id: ${SGIS_SERVICE_ID} service-secret: ${SGIS_SERVICE_SECRET} aws: - region: ${AWS_REGION} \ No newline at end of file + region: ${AWS_REGION} + access-key: ${AWS_ACCESS_KEY_ID:} + secret-key: ${AWS_SECRET_ACCESS_KEY:} \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index c5b98820..8809f35b 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -65,3 +65,5 @@ sgis: service-secret: mock-sgis-service-secret aws: region: mockocko + access-key: mockmockmockmock + secret-key: mockmockmockmock From e70aa5049f33204e317d2bb8f5fe98b79902d63a Mon Sep 17 00:00:00 2001 From: hodoon Date: Sat, 14 Feb 2026 14:40:56 -0500 Subject: [PATCH 12/12] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=EA=B8=B0=EB=8A=A5=EA=B3=BC=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=EA=B8=B0=EB=8A=A5=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...erifyEmailVerificationCodeResponseDto.java | 16 ++++++ .../user/controller/UserPublicController.java | 7 +-- .../controller/UserPublicControllerSpec.java | 7 ++- .../user/dto/CreateUserRequestDto.java | 4 ++ ...disEmailVerificationTokenStoreAdapter.java | 54 ++++++++++++++----- .../usecase/VerifyEmailVerificationCode.java | 9 +++- .../application/user/usecase/CreateUser.java | 16 +++++- .../alter/common/exception/ErrorCode.java | 1 + .../VerifyEmailVerificationCodeUseCase.java | 3 +- .../EmailVerificationTokenStorePort.java | 15 ++++-- 10 files changed, 108 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/VerifyEmailVerificationCodeResponseDto.java diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/VerifyEmailVerificationCodeResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/VerifyEmailVerificationCodeResponseDto.java new file mode 100644 index 00000000..060cba43 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/VerifyEmailVerificationCodeResponseDto.java @@ -0,0 +1,16 @@ +package com.dreamteam.alter.adapter.inbound.general.email.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "이메일 인증 성공 응답") +public class VerifyEmailVerificationCodeResponseDto { + + @Schema(description = "이메일 인증 세션 토큰", example = "a1b2c3d4-e5f6-4a09-8c13-1b2a3d4e5f6a") + private String verificationToken; +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicController.java index 51d24796..c3c3ae9b 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicController.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicController.java @@ -3,6 +3,7 @@ import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; import com.dreamteam.alter.adapter.inbound.general.email.dto.SendEmailVerificationCodeRequestDto; import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeRequestDto; +import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeResponseDto; import com.dreamteam.alter.adapter.inbound.general.user.dto.*; import com.dreamteam.alter.domain.email.port.inbound.SendEmailVerificationCodeUseCase; import com.dreamteam.alter.domain.email.port.inbound.VerifyEmailVerificationCodeUseCase; @@ -158,10 +159,10 @@ public ResponseEntity> sendVerificationCode( @Override @PostMapping("/email/verify") - public ResponseEntity> verifyVerificationCode( + public ResponseEntity> verifyVerificationCode( @Valid @RequestBody VerifyEmailVerificationCodeRequestDto request ) { - verifyEmailVerificationCode.execute(request); - return ResponseEntity.ok(CommonApiResponse.empty()); + VerifyEmailVerificationCodeResponseDto response = verifyEmailVerificationCode.execute(request); + return ResponseEntity.ok(CommonApiResponse.of(response)); } } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java index abbeccf2..95b5c616 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java @@ -4,6 +4,7 @@ import com.dreamteam.alter.adapter.inbound.common.dto.ErrorResponse; import com.dreamteam.alter.adapter.inbound.general.email.dto.SendEmailVerificationCodeRequestDto; import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeRequestDto; +import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeResponseDto; import com.dreamteam.alter.adapter.inbound.general.user.dto.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -91,6 +92,10 @@ public interface UserPublicControllerSpec { @ExampleObject( name = "비밀번호 형식 오류", value = "{\"success\": false, \"code\" : \"A014\", \"message\" : \"비밀번호는 8~16자 이내 영문, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다.\"}" + ), + @ExampleObject( + name = "이메일 인증 세션 오류", + value = "{\"success\": false, \"code\" : \"A015\", \"message\" : \"이메일 인증 세션이 유효하지 않거나 만료되었습니다.\" }" ) })) }) @@ -277,7 +282,7 @@ public interface UserPublicControllerSpec { ) ) }) - ResponseEntity> verifyVerificationCode(@Valid VerifyEmailVerificationCodeRequestDto request); + ResponseEntity> verifyVerificationCode(@Valid VerifyEmailVerificationCodeRequestDto request); } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserRequestDto.java index fc003a60..221cfd7c 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserRequestDto.java @@ -51,4 +51,8 @@ public class CreateUserRequestDto { @Schema(description = "생년월일", example = "YYYYMMDD") private String birthday; + @NotBlank + @Schema(description = "이메일 인증 성공 후 받은 세션 토큰") + private String emailVerificationToken; + } diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/email/redis/RedisEmailVerificationTokenStoreAdapter.java b/src/main/java/com/dreamteam/alter/adapter/outbound/email/redis/RedisEmailVerificationTokenStoreAdapter.java index 4ab5eabf..a0be4a80 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/email/redis/RedisEmailVerificationTokenStoreAdapter.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/email/redis/RedisEmailVerificationTokenStoreAdapter.java @@ -7,6 +7,7 @@ import java.time.Duration; import java.util.Optional; +import java.util.UUID; @Component @RequiredArgsConstructor @@ -18,8 +19,11 @@ public class RedisEmailVerificationTokenStoreAdapter implements EmailVerificatio private static final String KEY_PREFIX_VERIFIED = "auth:email:verified:"; private static final String KEY_PREFIX_COOLDOWN = "auth:email:cooldown:"; private static final String KEY_PREFIX_ATTEMPTS = "auth:email:attempts:"; + private static final String KEY_PREFIX_SESSION = "auth:email:session:"; + // --- 인증 코드 관련 --- + @Override public void saveCode(String email, String code, Duration ttl) { redisTemplate.delete(KEY_PREFIX_ATTEMPTS + email); @@ -35,8 +39,34 @@ public Optional findCode(String email) { @Override public void deleteCode(String email) { redisTemplate.delete(KEY_PREFIX_CODE + email); + redisTemplate.delete(KEY_PREFIX_ATTEMPTS + email); + } + + @Override + public long incrementAttempt(String email, Duration ttl) { + String key = KEY_PREFIX_ATTEMPTS + email; + Long attempts = redisTemplate.opsForValue().increment(key); + if (attempts != null && attempts == 1) { + // 처음 생성된 키라면 TTL 설정 (코드 TTL과 맞추거나 별도 설정) + redisTemplate.expire(key, ttl); + } + return attempts != null ? attempts : 1L; + } + + // --- 쿨다운 관련 --- + + @Override + public boolean isCooldown(String email) { + return redisTemplate.hasKey(KEY_PREFIX_COOLDOWN + email); } + @Override + public void markCooldown(String email, Duration ttl) { + redisTemplate.opsForValue().set(KEY_PREFIX_COOLDOWN + email, "true", ttl); + } + + // --- 인증 완료(Verified) 마킹 --- + @Override public void markVerified(String email, Duration ttl) { redisTemplate.opsForValue().set(KEY_PREFIX_VERIFIED + email, "true", ttl); @@ -47,24 +77,24 @@ public boolean isVerified(String email) { return redisTemplate.hasKey(KEY_PREFIX_VERIFIED + email); } + // --- 인증 세션 토큰 관련 (신규) --- + @Override - public boolean isCooldown(String email) { - return redisTemplate.hasKey(KEY_PREFIX_COOLDOWN + email); + public String createVerificationSession(String email, Duration ttl) { + String token = UUID.randomUUID().toString(); + // Key: 토큰, Value: 이메일 + redisTemplate.opsForValue().set(KEY_PREFIX_SESSION + email, token, ttl); + return token; } @Override - public void markCooldown(String email, Duration ttl) { - redisTemplate.opsForValue().set(KEY_PREFIX_COOLDOWN + email, "true", ttl); + public Optional getEmailBySession(String token) { + String email = redisTemplate.opsForValue().get(KEY_PREFIX_SESSION + token); + return Optional.ofNullable(email); } @Override - public long incrementAttempt(String email, Duration ttl) { - String key = KEY_PREFIX_ATTEMPTS + email; - Long attempts = redisTemplate.opsForValue().increment(key); - if (attempts != null && attempts == 1) { - // 처음 생성된 키라면 TTL 설정 (코드 TTL과 맞추거나 별도 설정) - redisTemplate.expire(key, ttl); - } - return attempts != null ? attempts : 1L; + public void deleteSession(String token) { + redisTemplate.delete(KEY_PREFIX_SESSION + token); } } diff --git a/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java b/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java index cd32f28e..a05da5d6 100644 --- a/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java +++ b/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java @@ -1,6 +1,7 @@ package com.dreamteam.alter.application.email.usecase; import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeRequestDto; +import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeResponseDto; import com.dreamteam.alter.application.email.properties.EmailAuthProperties; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; @@ -21,7 +22,7 @@ public class VerifyEmailVerificationCode implements VerifyEmailVerificationCodeU private final EmailAuthProperties properties; @Override - public void execute(VerifyEmailVerificationCodeRequestDto request) { + public VerifyEmailVerificationCodeResponseDto execute(VerifyEmailVerificationCodeRequestDto request) { String email = request.getEmail(); String inputCode = request.getCode(); @@ -44,6 +45,10 @@ public void execute(VerifyEmailVerificationCodeRequestDto request) { // Success -> Delete Code & Mark Verified tokenStorePort.deleteCode(email); - tokenStorePort.markVerified(email, Duration.ofSeconds(properties.getVerifiedTtlSeconds())); + String verificationToken = tokenStorePort.createVerificationSession( + email, Duration.ofSeconds(properties.getVerifiedTtlSeconds()) + ); + + return new VerifyEmailVerificationCodeResponseDto(verificationToken); } } diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUser.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUser.java index da95165e..d5a5c27b 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUser.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUser.java @@ -8,6 +8,7 @@ import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.common.util.PasswordValidator; import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationTokenStorePort; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.user.port.inbound.CreateUserUseCase; import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; @@ -34,9 +35,21 @@ public class CreateUser implements CreateUserUseCase { private final PasswordEncoder passwordEncoder; private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; + private final EmailVerificationTokenStorePort emailVerificationTokenStorePort; @Override public GenerateTokenResponseDto execute(CreateUserRequestDto request) { + + // 이메일 인증 세션 검증 + String token = request.getEmailVerificationToken(); + String verifiedEmail = emailVerificationTokenStorePort.getEmailBySession(token) + .orElseThrow(() -> new CustomException(ErrorCode.EMAIL_VERIFICATION_SESSION_INVALID)); + + // 토큰의 이메일과 요청의 이메일이 일치하는지 확인 (보안 강화) + if (!verifiedEmail.equals(request.getEmail())) { + throw new CustomException(ErrorCode.EMAIL_VERIFICATION_SESSION_INVALID); + } + // Redis 세션에서 휴대폰 인증 정보 확인 String sessionIdKey = KEY_PREFIX + request.getSignupSessionId(); String userInfoJson = redisTemplate.opsForValue().get(sessionIdKey); @@ -71,8 +84,9 @@ public GenerateTokenResponseDto execute(CreateUserRequestDto request) { request.getBirthday() )); - // 세션 삭제 + // 세션 삭제 (휴대폰 인증 세션 & 이메일 인증 세션) redisTemplate.delete(sessionIdKey); + emailVerificationTokenStorePort.deleteSession(token); return GenerateTokenResponseDto.of(authService.generateAuthorization(user, TokenScope.APP)); } catch (JsonProcessingException e) { diff --git a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java index 81f75308..cba3157a 100644 --- a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java +++ b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java @@ -21,6 +21,7 @@ public enum ErrorCode { SOCIAL_PROVIDER_ALREADY_LINKED(400, "A012", "이미 연동되어 있는 소셜 플랫폼입니다."), PASSWORD_RESET_SESSION_NOT_EXIST(400, "A013", "비밀번호 재설정 세션이 존재하지 않거나 만료되었습니다."), INVALID_PASSWORD_FORMAT(400, "A014", "비밀번호는 8~16자 이내 영문, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다."), + EMAIL_VERIFICATION_SESSION_INVALID(400, "A015", "이메일 인증 세션이 유효하지 않거나 만료되었습니다."), ILLEGAL_ARGUMENT(400, "B001", "잘못된 요청입니다."), REFRESH_TOKEN_REQUIRED(400, "B002", "RefreshToken을 통해 요청해야 합니다."), diff --git a/src/main/java/com/dreamteam/alter/domain/email/port/inbound/VerifyEmailVerificationCodeUseCase.java b/src/main/java/com/dreamteam/alter/domain/email/port/inbound/VerifyEmailVerificationCodeUseCase.java index 3c7a4a2b..428877c1 100644 --- a/src/main/java/com/dreamteam/alter/domain/email/port/inbound/VerifyEmailVerificationCodeUseCase.java +++ b/src/main/java/com/dreamteam/alter/domain/email/port/inbound/VerifyEmailVerificationCodeUseCase.java @@ -1,7 +1,8 @@ package com.dreamteam.alter.domain.email.port.inbound; import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeRequestDto; +import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeResponseDto; public interface VerifyEmailVerificationCodeUseCase { - void execute(VerifyEmailVerificationCodeRequestDto request); + VerifyEmailVerificationCodeResponseDto execute(VerifyEmailVerificationCodeRequestDto request); } diff --git a/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailVerificationTokenStorePort.java b/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailVerificationTokenStorePort.java index 04771d24..a5a66962 100644 --- a/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailVerificationTokenStorePort.java +++ b/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailVerificationTokenStorePort.java @@ -4,15 +4,22 @@ import java.util.Optional; public interface EmailVerificationTokenStorePort { + // --- 인증 코드 관련 --- void saveCode(String email, String code, Duration ttl); Optional findCode(String email); void deleteCode(String email); + long incrementAttempt(String email, Duration ttl); - void markVerified(String email, Duration ttl); - boolean isVerified(String email); - + // --- 쿨다운 관련 --- boolean isCooldown(String email); void markCooldown(String email, Duration ttl); - long incrementAttempt(String email, Duration ttl); + // -- 인증 세션 관련 --- + String createVerificationSession(String email, Duration ttl); + Optional getEmailBySession(String token); + void deleteSession(String token); + + + void markVerified(String email, Duration ttl); + boolean isVerified(String email); }