diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e94ed30..fede930 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,6 +61,8 @@ jobs: AWS_SECRET_KEY: dummy AWS_REGION: ap-northeast-2 AWS_S3_BUCKET: dummy-bucket + SPRING_JWT_PRIVATE_KEY: file:/tmp/test-keys/private_key.pem + SPRING_JWT_PUBLIC_KEY: file:/tmp/test-keys/public_key.pem run: ./gradlew test - name: Upload test report diff --git a/src/main/java/chainsawman/gesture/controller/AuthController.java b/src/main/java/chainsawman/gesture/controller/AuthController.java index 8de305a..6c280ba 100644 --- a/src/main/java/chainsawman/gesture/controller/AuthController.java +++ b/src/main/java/chainsawman/gesture/controller/AuthController.java @@ -62,6 +62,20 @@ public ResponseEntity> deleteRefreshToken(@RequestBody @Valid return ResponseEntity.ok(ApiResponse.ok("삭제되었습니다.")); } + @PostMapping("/email-send") + @Operation(summary = "이메일 인증 발송", description = "이메일 인증 코드를 발송합니다.") + public ResponseEntity> sendEmailVerification(@RequestBody @Valid EmailSendRequest request) { + authService.sendEmailVerification(request); + return ResponseEntity.ok(ApiResponse.ok("인증 코드가 발송되었습니다.")); + } + + @PostMapping("/email-verification") + @Operation(summary = "이메일 인증 확인", description = "이메일 인증 코드를 검증합니다.") + public ResponseEntity> verifyEmail(@RequestBody @Valid EmailVerificationRequest request) { + EmailVerificationResponse result = authService.verifyEmail(request); + return ResponseEntity.ok(ApiResponse.ok(result)); + } + diff --git a/src/main/java/chainsawman/gesture/dto/user/request/EmailSendRequest.java b/src/main/java/chainsawman/gesture/dto/user/request/EmailSendRequest.java new file mode 100644 index 0000000..5608335 --- /dev/null +++ b/src/main/java/chainsawman/gesture/dto/user/request/EmailSendRequest.java @@ -0,0 +1,13 @@ +package chainsawman.gesture.dto.user.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + +@Getter +public class EmailSendRequest { + + @NotBlank + @Email + private String email; +} diff --git a/src/main/java/chainsawman/gesture/dto/user/request/EmailVerificationRequest.java b/src/main/java/chainsawman/gesture/dto/user/request/EmailVerificationRequest.java new file mode 100644 index 0000000..c2ba7e0 --- /dev/null +++ b/src/main/java/chainsawman/gesture/dto/user/request/EmailVerificationRequest.java @@ -0,0 +1,18 @@ +package chainsawman.gesture.dto.user.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; + +@Getter +public class EmailVerificationRequest { + + @NotBlank + @Email + private String email; + + @NotBlank + @Pattern(regexp = "\\d{6}") + private String code; +} diff --git a/src/main/java/chainsawman/gesture/dto/user/response/EmailVerificationResponse.java b/src/main/java/chainsawman/gesture/dto/user/response/EmailVerificationResponse.java new file mode 100644 index 0000000..ac1c15b --- /dev/null +++ b/src/main/java/chainsawman/gesture/dto/user/response/EmailVerificationResponse.java @@ -0,0 +1,18 @@ +package chainsawman.gesture.dto.user.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class EmailVerificationResponse { + + private boolean verified; + private String email; + + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime verifiedAt; +} diff --git a/src/main/java/chainsawman/gesture/service/AuthService.java b/src/main/java/chainsawman/gesture/service/AuthService.java index 6a5fc33..8d2d5c1 100644 --- a/src/main/java/chainsawman/gesture/service/AuthService.java +++ b/src/main/java/chainsawman/gesture/service/AuthService.java @@ -5,7 +5,9 @@ import chainsawman.gesture.entity.user.RefreshToken; import chainsawman.gesture.entity.user.User; import chainsawman.gesture.enums.ProviderType; +import chainsawman.gesture.exceptions.auth.ExpiredVerificationCodeException; import chainsawman.gesture.exceptions.auth.InvalidRefreshTokenException; +import chainsawman.gesture.exceptions.auth.InvalidVerificationCodeException; import chainsawman.gesture.exceptions.user.DeactivatedUserException; import chainsawman.gesture.exceptions.user.DuplicateEmailException; import chainsawman.gesture.exceptions.user.DuplicateIdException; @@ -16,23 +18,32 @@ import chainsawman.gesture.global.TokenProvider; import chainsawman.gesture.repository.user.RefreshTokenRepository; import chainsawman.gesture.repository.user.UserRepository; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import java.security.SecureRandom; import java.time.LocalDateTime; import java.util.Optional; +import java.util.concurrent.TimeUnit; @Service @RequiredArgsConstructor @Transactional public class AuthService { + private static final String EMAIL_VERIFICATION_PREFIX = "email:verification:"; + private static final long VERIFICATION_TTL_MINUTES = 5; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final TokenProvider tokenProvider; private final MediaService mediaService; private final RefreshTokenRepository refreshTokenRepository; + private final StringRedisTemplate redisTemplate; + private final EmailService emailService; // 로그인 @@ -205,6 +216,36 @@ public void deleteRefreshToken(RefreshTokenRequest request) { + // 이메일 인증 코드 발송 + public void sendEmailVerification(EmailSendRequest request) { + String code = String.format("%06d", SECURE_RANDOM.nextInt(1_000_000)); + String key = EMAIL_VERIFICATION_PREFIX + request.getEmail(); + redisTemplate.opsForValue().set(key, code, VERIFICATION_TTL_MINUTES, TimeUnit.MINUTES); + emailService.sendVerificationCode(request.getEmail(), code); + } + + // 이메일 인증 코드 확인 + public EmailVerificationResponse verifyEmail(EmailVerificationRequest request) { + String key = EMAIL_VERIFICATION_PREFIX + request.getEmail(); + String stored = redisTemplate.opsForValue().get(key); + + if (stored == null) { + throw new ExpiredVerificationCodeException(); + } + if (!stored.equals(request.getCode())) { + throw new InvalidVerificationCodeException(); + } + + redisTemplate.delete(key); + + LocalDateTime verifiedAt = LocalDateTime.now(); + return EmailVerificationResponse.builder() + .verified(true) + .email(request.getEmail()) + .verifiedAt(verifiedAt) + .build(); + } + // 리프레시 토큰 저장 (있으면 업데이트 없으면 생성) private void saveRefreshToken(User user, String refreshToken) { LocalDateTime expiresAt = LocalDateTime.now().plusDays(7); diff --git a/src/main/java/chainsawman/gesture/service/EmailService.java b/src/main/java/chainsawman/gesture/service/EmailService.java new file mode 100644 index 0000000..cb3342b --- /dev/null +++ b/src/main/java/chainsawman/gesture/service/EmailService.java @@ -0,0 +1,38 @@ +package chainsawman.gesture.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Map; + +@Service +public class EmailService { + + private static final String BREVO_API_URL = "https://api.brevo.com/v3/smtp/email"; + + @Value("${brevo.api.key}") + private String apiKey; + + @Value("${brevo.template.verification-id}") + private int templateId; + + private final RestTemplate restTemplate = new RestTemplate(); + + public void sendVerificationCode(String toEmail, String code) { + HttpHeaders headers = new HttpHeaders(); + headers.set("api-key", apiKey); + headers.set("accept", "application/json"); + headers.setContentType(MediaType.APPLICATION_JSON); + + Map body = Map.of( + "to", List.of(Map.of("email", toEmail)), + "templateId", templateId, + "params", Map.of("code", code) + ); + + restTemplate.postForEntity(BREVO_API_URL, new HttpEntity<>(body, headers), String.class); + } +} diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 1cddb68..1b8089b 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -18,3 +18,7 @@ springdoc.swagger-ui.enabled=false # redis spring.data.redis.host=gesture-redis + +# mail (Brevo API) +brevo.api.key=${BREVO_API_KEY} +brevo.template.verification-id=${BREVO_TEMPLATE_VERIFICATION_ID} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d28f1df..547fb5e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -25,4 +25,5 @@ aws.s3.bucket=${AWS_S3_BUCKET} aws.region=${AWS_REGION} # redis -spring.data.redis.port=6379 \ No newline at end of file +spring.data.redis.port=6379 + diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..53f52d7 --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,20 @@ +spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect + +spring.jwt.private-key=classpath:keys/private_key.pem +spring.jwt.public-key=classpath:keys/public_key.pem + +aws.region=ap-northeast-2 +aws.s3.bucket=test-bucket +cloud.aws.stack.auto=false +aws.disable-ec2-metadata=true + +spring.data.redis.host=localhost + +brevo.api.key=test-dummy-key +brevo.template.verification-id=1