Skip to content
6 changes: 5 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/com/dreamteam/alter/AlterApplication.java
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = "[email protected]")
private String email;
}
Original file line number Diff line number Diff line change
@@ -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 = "[email protected]")
private String email;

@NotBlank
@Pattern(regexp = "^[0-9]{6}$", message = "인증 코드는 6자리 숫자여야 합니다.")
@Schema(description = "수신한 인증 코드 6자리", example = "123456")
private String code;
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<CommonApiResponse<CreateSignupSessionResponseDto>> createSignupSession(
Expand Down Expand Up @@ -135,4 +146,22 @@ public ResponseEntity<CommonApiResponse<Void>> resetPassword(
resetPassword.execute(request);
return ResponseEntity.ok(CommonApiResponse.empty());
}

@Override
@PostMapping("/email/send")
public ResponseEntity<CommonApiResponse<Void>> sendVerificationCode(
@Valid @RequestBody SendEmailVerificationCodeRequestDto request
) {
sendEmailVerificationCode.execute(request);
return ResponseEntity.ok(CommonApiResponse.empty());
}

@Override
@PostMapping("/email/verify")
public ResponseEntity<CommonApiResponse<Void>> verifyVerificationCode(
@Valid @RequestBody VerifyEmailVerificationCodeRequestDto request
) {
verifyEmailVerificationCode.execute(request);
return ResponseEntity.ok(CommonApiResponse.empty());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -215,4 +217,67 @@ public interface UserPublicControllerSpec {
})
ResponseEntity<CommonApiResponse<Void>> 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<CommonApiResponse<Void>> 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<CommonApiResponse<Void>> verifyVerificationCode(@Valid VerifyEmailVerificationCodeRequestDto request);


}
Original file line number Diff line number Diff line change
@@ -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);
}

}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<EmailSendLog, Long> {
}
Original file line number Diff line number Diff line change
@@ -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<EmailSendLog> findById(Long id) {
return jpaRepository.findById(id);
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Loading