diff --git a/build.gradle b/build.gradle index efc1b75..5b7b691 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 419357b..651ea89 100644 --- a/src/main/java/com/dreamteam/alter/AlterApplication.java +++ b/src/main/java/com/dreamteam/alter/AlterApplication.java @@ -1,13 +1,19 @@ 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; +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 { public static void main(String[] args) { diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/SendEmailVerificationCodeRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/SendEmailVerificationCodeRequestDto.java new file mode 100644 index 0000000..b86b064 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/SendEmailVerificationCodeRequestDto.java @@ -0,0 +1,20 @@ +package com.dreamteam.alter.adapter.inbound.general.email.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; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@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/email/dto/VerifyEmailVerificationCodeRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/VerifyEmailVerificationCodeRequestDto.java new file mode 100644 index 0000000..864f6d3 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/email/dto/VerifyEmailVerificationCodeRequestDto.java @@ -0,0 +1,26 @@ +package com.dreamteam.alter.adapter.inbound.general.email.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; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@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; +} 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 ebdba82..51d2479 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 702cc98..abbeccf 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,67 @@ 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), + 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 = "발송된 인증 코드를 검증합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "인증 코드 검증 성공"), + @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/aws/ses/SesEmailSenderAdapter.java b/src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/SesEmailSenderAdapter.java new file mode 100644 index 0000000..6e3ee53 --- /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 0000000..fbd68eb --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/config/AwsSesConfig.java @@ -0,0 +1,35 @@ +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 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 +public class AwsSesConfig { + + private final AwsProperties awsProperties; + + @Bean + public SesClient sesClient() { + 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 new file mode 100644 index 0000000..63b291f --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/aws/ses/properties/AwsProperties.java @@ -0,0 +1,14 @@ +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; + private String accessKey; + private String secretKey; +} 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 0000000..08d171e --- /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 0000000..e9eeaee --- /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 0000000..d4e17ad --- /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/email/redis/RedisEmailVerificationTokenStoreAdapter.java b/src/main/java/com/dreamteam/alter/adapter/outbound/email/redis/RedisEmailVerificationTokenStoreAdapter.java new file mode 100644 index 0000000..4ab5eab --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/email/redis/RedisEmailVerificationTokenStoreAdapter.java @@ -0,0 +1,70 @@ +package com.dreamteam.alter.adapter.outbound.email.redis; + +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:"; + 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); + } + + @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); + } + + @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/event/EmailSendEvent.java b/src/main/java/com/dreamteam/alter/application/email/event/EmailSendEvent.java new file mode 100644 index 0000000..99dec59 --- /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 0000000..1b7f5ce --- /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/properties/EmailAuthProperties.java b/src/main/java/com/dreamteam/alter/application/email/properties/EmailAuthProperties.java new file mode 100644 index 0000000..48ecf50 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/email/properties/EmailAuthProperties.java @@ -0,0 +1,16 @@ +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; + private int maxAttempts = 5; +} 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 0000000..eb8471d --- /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 0000000..b1ac984 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/email/usecase/SendEmailVerificationCode.java @@ -0,0 +1,65 @@ +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.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 +@Transactional +public class SendEmailVerificationCode implements SendEmailVerificationCodeUseCase { + + private final EmailVerificationTokenStorePort tokenStorePort; + private final EmailSendLogPort emailSendLogPort; + private final VerificationCodeGenerator codeGenerator; + private final EmailAuthProperties properties; + private final ApplicationEventPublisher eventPublisher; + + + @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())); + + // 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 + EmailSendLog saved = emailSendLogPort.save(log); + + eventPublisher.publishEvent(new EmailSendEvent(saved.getId())); + } +} 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 0000000..cd32f28 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/email/usecase/VerifyEmailVerificationCode.java @@ -0,0 +1,49 @@ +package com.dreamteam.alter.application.email.usecase; + +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; +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 +public class VerifyEmailVerificationCode implements VerifyEmailVerificationCodeUseCase { + + private final EmailVerificationTokenStorePort tokenStorePort; + private final EmailAuthProperties properties; + + @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)) { + // 시도 횟수 증가 + 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); + } + + // 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 4d37ecc..81f7530 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,13 @@ 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", "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), + 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/entity/EmailSendLog.java b/src/main/java/com/dreamteam/alter/domain/email/entity/EmailSendLog.java new file mode 100644 index 0000000..d3ea535 --- /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/inbound/SendEmailVerificationCodeUseCase.java b/src/main/java/com/dreamteam/alter/domain/email/port/inbound/SendEmailVerificationCodeUseCase.java new file mode 100644 index 0000000..d6eb848 --- /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.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 new file mode 100644 index 0000000..3c7a4a2 --- /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.email.dto.VerifyEmailVerificationCodeRequestDto; + +public interface VerifyEmailVerificationCodeUseCase { + void execute(VerifyEmailVerificationCodeRequestDto request); +} 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 0000000..5f53315 --- /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/port/outbound/EmailSenderPort.java b/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailSenderPort.java new file mode 100644 index 0000000..53b5e27 --- /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 0000000..04771d2 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/email/port/outbound/EmailVerificationTokenStorePort.java @@ -0,0 +1,18 @@ +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); + + long incrementAttempt(String email, Duration ttl); +} 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 0000000..e42b8a9 --- /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 +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 54b48e4..a370c2c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -55,10 +55,20 @@ 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} + max-attempts: ${EMAIL_MAX_ATTEMPTS:5} 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} + 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 3910362..8809f35 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,7 @@ firebase: sgis: service-id: mock-sgis-service-id service-secret: mock-sgis-service-secret +aws: + region: mockocko + access-key: mockmockmockmock + secret-key: mockmockmockmock