diff --git a/backend/build.gradle b/backend/build.gradle index 50e210a1..472159ba 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -46,6 +46,15 @@ dependencies { testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // Mockito + AssertJ + testImplementation "org.mockito:mockito-junit-jupiter:5.12.0" + testImplementation "org.assertj:assertj-core:3.26.3" + + // GreenMail: SMTP 가짜 서버 + testImplementation "com.icegreen:greenmail:1.6.14" + + // spring-retry implementation 'org.springframework.retry:spring-retry' @@ -60,6 +69,17 @@ dependencies { // backtesting library implementation 'org.ta4j:ta4j-core:0.15' + // mail + implementation 'org.springframework.boot:spring-boot-starter-mail' + + //validation + implementation 'commons-validator:commons-validator:1.10.0' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // Thymleaf + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' } jacoco { diff --git a/backend/src/main/java/org/sejongisc/backend/auth/config/EmailProperties.java b/backend/src/main/java/org/sejongisc/backend/auth/config/EmailProperties.java new file mode 100644 index 00000000..c28e78ad --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/auth/config/EmailProperties.java @@ -0,0 +1,33 @@ +package org.sejongisc.backend.auth.config; + +import java.time.Duration; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@ConfigurationProperties(prefix = "email") +@Getter +@Setter +@Configuration +public class EmailProperties { + private Duration codeExpire; + private Duration verifiedExpire; + private KeyPrefix keyPrefix; + private Code code; + + @Setter + @Getter + public static class KeyPrefix { + private String verify; + private String verified; + } + + @Setter + @Getter + public static class Code { + private String charset; // 문자 세트 + private int length; // 기본 길이 + } + +} diff --git a/backend/src/main/java/org/sejongisc/backend/auth/controller/EmailController.java b/backend/src/main/java/org/sejongisc/backend/auth/controller/EmailController.java new file mode 100644 index 00000000..d0ecfece --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/auth/controller/EmailController.java @@ -0,0 +1,74 @@ +package org.sejongisc.backend.auth.controller; + + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.sejongisc.backend.auth.service.EmailService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag( + name = "메일 API", + description = "메일 관련 API 제공" +) +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/email") +public class EmailController { + private final EmailService emailService; + + @Operation( + summary = "메일전송", + description = """ + ## 인증(JWT): **불필요** + + ## 요청 파라미터 (String) + - **`email`**: 회원 이메일 + + ## 반환값 (ResponseEntity) + - **`message`**: 전송완료 메세지 + + ## 에러코드 + - **`EMAIL_INVALID_EMAIL`**: 유효하지 않은 이메일입니다 + - **`DUPLICATE_EMAIL`**: 이미 존재하는 이메일입니다 + - **`EMAIL_ALREADY_VERIFIED`**: 24시간 내에 인증된 이메일입니다 + """ + ) + @PostMapping("/send") + public ResponseEntity sendEmail(@RequestParam String email) { + emailService.sendEmail(email); + return ResponseEntity.ok("메일 전송을 요청하였습니다."); + } + + + @Operation( + summary = "이메일 인증", + description = """ + ## 인증(JWT): **불필요** + + ## 요청 파라미터 (String) + - **`email`**: 회원 이메일 + - **`code`**: 이메일 인증 코드 + + ## 반환값 (ResponseEntity) + - **`message`**: 인증 완료 메시지 + + ## 에러코드 + - **`EMAIL_CODE_MISMATCH`**: 이메일 인증 코드가 일치하지 않습니다 + - **`EMAIL_CODE_NOT_FOUND`**: 이메일 인증 코드를 찾을 수 없습니다 + """ + ) + @PostMapping("/verify") + public ResponseEntity verifyEmail(@RequestParam String email, @RequestParam String code) { + emailService.verifyEmail(email, code); + return ResponseEntity.ok("이메일 인증이 완료되었습니다."); + } + + + +} diff --git a/backend/src/main/java/org/sejongisc/backend/auth/service/EmailService.java b/backend/src/main/java/org/sejongisc/backend/auth/service/EmailService.java new file mode 100644 index 00000000..af3f4998 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/auth/service/EmailService.java @@ -0,0 +1,134 @@ +package org.sejongisc.backend.auth.service; + +import jakarta.mail.Message; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import jakarta.validation.constraints.Email; +import java.security.SecureRandom; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.validator.routines.EmailValidator; +import org.sejongisc.backend.auth.config.EmailProperties; +import org.sejongisc.backend.common.exception.CustomException; +import org.sejongisc.backend.common.exception.ErrorCode; +import org.sejongisc.backend.user.dao.UserRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.mail.MailSendException; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; + +@Slf4j +@Service +@RequiredArgsConstructor +@Validated +public class EmailService { + private final JavaMailSender mailSender; + private final RedisTemplate redisTemplate; + private final SpringTemplateEngine templateEngine; + private final UserRepository userRepository; + private final EmailProperties emailProperties; + + // 메일 발신자 + @Value("${spring.mail.username}") + private String from; + + // 메세지 만들기 + private MimeMessage createMessage(String email, String code) throws MessagingException { + MimeMessage message = mailSender.createMimeMessage(); + message.setFrom(new InternetAddress(from)); + message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(email)); + message.setSubject("세투연 이메일 인증 메일입니다."); + + Context context = new Context(); + context.setVariable("email", email); + context.setVariable("code", code); + + String body = templateEngine.process("mail/verificationEmail", context); + message.setText(body, "UTF-8", "html"); + + return message; + + } + + // 메일 발송 + public void sendEmail(@Email String email) { + + // 이미 24시간 내 인증된 이메일인지 확인 + String verifiedKey = emailProperties.getKeyPrefix().getVerified() + email; + if (Boolean.TRUE.equals(redisTemplate.hasKey(verifiedKey))) { + throw new CustomException(ErrorCode.EMAIL_ALREADY_VERIFIED); + } + + // 이메일 형식 검증 + if (!EmailValidator.getInstance().isValid(email)) { + throw new CustomException(ErrorCode.EMAIL_INVALID_EMAIL); + } + + // 중복 이메일 검증 + if (userRepository.existsByEmail(email)) { + throw new CustomException(ErrorCode.DUPLICATE_EMAIL); + } + + // 인증코드 생성 + String code = generateCode(); + + // Redis에 인증 코드 저장 (유효시간: 3분) + redisTemplate.opsForValue().set(emailProperties.getKeyPrefix().getVerify() + email, code, emailProperties.getCodeExpire()); + + // 메일 발송 + try { + MimeMessage message = createMessage(email, code); + mailSender.send(message); + } catch (MessagingException e) { + log.error("메일전송이 실패하였습니다", e); + throw new MailSendException("failed to send mail", e); + } + + } + + // 코드확인 + public void verifyEmail(String email, String code) { + String key = emailProperties.getKeyPrefix().getVerify()+ email; + + String storedCode = redisTemplate.opsForValue().get(key); + if (storedCode == null) throw new CustomException(ErrorCode.EMAIL_CODE_NOT_FOUND); + if (!storedCode.equals(code)) throw new CustomException(ErrorCode.EMAIL_CODE_MISMATCH); + + + // 인증 성공 시 Redis에서 코드 삭제 + redisTemplate.delete(key); + + // 인증 완료 상태 저장 (24시간 유효) + redisTemplate.opsForValue().set( + emailProperties.getKeyPrefix().getVerified() + email, + "true", + emailProperties.getVerifiedExpire() + ); + + + } + + // 이메일 인증 코드 생성 + private String generateCode() { + String charset = emailProperties.getCode().getCharset(); + int len = emailProperties.getCode().getLength(); + + SecureRandom rnd = new SecureRandom(); + StringBuilder sb = new StringBuilder(len); + + for (int i = 0; i < len; i++) { + sb.append(charset.charAt(rnd.nextInt(charset.length()))); + } + return sb.toString(); + } + + + + +} diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/config/SecurityConfig.java b/backend/src/main/java/org/sejongisc/backend/common/auth/config/SecurityConfig.java index 6ef54a47..3557bc62 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/config/SecurityConfig.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/config/SecurityConfig.java @@ -48,6 +48,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/auth/reissue", "/v3/api-docs/**", "/swagger-ui/**", + + "/api/email/**", "/swagger-resources/**", "/webjars/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() diff --git a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java index dc9a09cc..82515797 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java +++ b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java @@ -50,8 +50,11 @@ public enum ErrorCode { INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 엑세스 토큰입니다."), - EXPIRED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "엑세스 토큰이 만료되었습니다. 재발급이 필요합니다."), - + // EMAIL + EMAIL_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "이메일 인증 코드를 찾을 수 없습니다"), + EMAIL_CODE_MISMATCH(HttpStatus.BAD_REQUEST, "인증 코드가 일치하지 않습니다."), + EMAIL_INVALID_EMAIL(HttpStatus.BAD_REQUEST, "유효하지 않은 이메일입니다"), + EMAIL_ALREADY_VERIFIED(HttpStatus.BAD_REQUEST, "24시간 이내에 이미 인증된 이메일입니다."), // USER diff --git a/backend/src/main/resources/templates/mail/verificationEmail.html b/backend/src/main/resources/templates/mail/verificationEmail.html new file mode 100644 index 00000000..b877617c --- /dev/null +++ b/backend/src/main/resources/templates/mail/verificationEmail.html @@ -0,0 +1,123 @@ + + + + + 세투연 이메일 인증 + + + + + + +
+ 세투연 이메일 인증 코드가 도착했습니다. 3분 안에 인증을 완료하세요. +
+ + + + + +
+ + + + + + + + + + + + + + + +
+ + diff --git a/backend/src/test/java/org/sejongisc/backend/auth/service/EmailServiceTest.java b/backend/src/test/java/org/sejongisc/backend/auth/service/EmailServiceTest.java new file mode 100644 index 00000000..9c5484bb --- /dev/null +++ b/backend/src/test/java/org/sejongisc/backend/auth/service/EmailServiceTest.java @@ -0,0 +1,88 @@ +package org.sejongisc.backend.auth.service; + + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import jakarta.mail.internet.MimeMessage; +import java.time.Duration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sejongisc.backend.auth.config.EmailProperties; +import org.sejongisc.backend.user.dao.UserRepository; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.util.ReflectionTestUtils; +import org.thymeleaf.spring6.SpringTemplateEngine; + +@ExtendWith(MockitoExtension.class) +class EmailServiceTest { + + @Mock JavaMailSender mailSender; + @Mock + RedisTemplate redisTemplate; + @Mock + ValueOperations valueOps; + @Mock + SpringTemplateEngine templateEngine; + @Mock + UserRepository userRepository; + @Mock EmailProperties props; + + + + @InjectMocks + EmailService emailService; + + @BeforeEach + void setUp() { + // value 객체 필드 세팅 + ReflectionTestUtils.setField(emailService, "from", "noreply@test.com"); + // EmailProperties 더미 값 세팅 + var keyPrefix = new EmailProperties.KeyPrefix(); + keyPrefix.setVerify("verify:"); + keyPrefix.setVerified("verified:"); + var codeConf = new EmailProperties.Code(); + codeConf.setCharset("0123456789"); + codeConf.setLength(6); + + given(props.getKeyPrefix()).willReturn(keyPrefix); + given(props.getCode()).willReturn(codeConf); + given(props.getCodeExpire()).willReturn(Duration.ofMinutes(3)); +// given(props.getVerifiedExpire()).willReturn(Duration.ofHours(24)); + given(redisTemplate.opsForValue()).willReturn(valueOps); + } + + @Test + void sendEmail_success_savesCodeAndSendsMail() throws Exception { + // Given + var email = "user@example.com"; + given(redisTemplate.hasKey("verified:" + email)).willReturn(false); + given(userRepository.existsByEmail(email)).willReturn(false); + var mime = Mockito.mock(MimeMessage.class); + given(mailSender.createMimeMessage()).willReturn(mime); + given(templateEngine.process(any(String.class), any(org.thymeleaf.context.IContext.class))) + .willReturn("ok"); + + // When + emailService.sendEmail(email); + + // Then + then(valueOps).should().set(startsWith("verify:"), anyString(), eq(Duration.ofMinutes(3))); + then(mailSender).should().send(mime); + } + + @Test + void verifyEmail() { + } +} \ No newline at end of file