From 8636535c3670af7326d6666ec4831d88c08ac4d1 Mon Sep 17 00:00:00 2001 From: Minjae Chung Date: Wed, 25 Feb 2026 11:21:55 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[feat]=20=EB=A9=94=EC=9D=BC=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EB=A0=88=EC=9D=B4=ED=8A=B8=20=EB=A6=AC=EB=B0=8B=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=80=EC=A6=9D=20=EC=8B=9C=EB=8F=84=20=ED=9A=9F?= =?UTF-8?q?=EC=88=98=20=EC=A0=9C=ED=95=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MailVerificationServiceImpl.java | 26 ++++++++-- .../auth/exception/AuthErrorStatus.java | 3 ++ .../auth/repository/RateLimitRepository.java | 48 +++++++++++++++++++ src/main/resources/application.yml | 2 +- 4 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/daramg/server/auth/repository/RateLimitRepository.java diff --git a/src/main/java/com/daramg/server/auth/application/MailVerificationServiceImpl.java b/src/main/java/com/daramg/server/auth/application/MailVerificationServiceImpl.java index 340a66a..70576f0 100644 --- a/src/main/java/com/daramg/server/auth/application/MailVerificationServiceImpl.java +++ b/src/main/java/com/daramg/server/auth/application/MailVerificationServiceImpl.java @@ -13,6 +13,7 @@ import com.daramg.server.user.repository.UserRepository; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; +import com.daramg.server.auth.repository.RateLimitRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.RedisConnectionFailureException; @@ -29,6 +30,7 @@ public class MailVerificationServiceImpl implements MailVerificationService{ private final MailContentBuilder mailContentBuilder; private final JavaMailSender javaMailSender; private final VerificationCodeRepository verificationCodeRepository; + private final RateLimitRepository rateLimitRepository; private final UserRepository userRepository; public void sendVerificationEmail(EmailVerificationRequestDto request) { @@ -60,12 +62,19 @@ private void sendForEmailChange(EmailVerificationRequestDto request) { } private void sendVerificationCode(EmailVerificationRequestDto request) { + executeRedisOperationVoid(() -> { + if (rateLimitRepository.isRateLimited(request.getEmail())) { + throw new BusinessException(AuthErrorStatus.EMAIL_RATE_LIMIT_EXCEEDED); + } + }); + String verificationCode = VerificationCodeGenerator.generate(); - + executeRedisOperationVoid(() -> { verificationCodeRepository.save(request.getEmail(), verificationCode); + rateLimitRepository.resetAttempts(request.getEmail()); }); - + try { String htmlContent = mailContentBuilder.buildVerificationEmail(verificationCode); MimeMessage mimeMessage = mimeMessageGenerator.generate( @@ -76,21 +85,30 @@ private void sendVerificationCode(EmailVerificationRequestDto request) { javaMailSender.send(mimeMessage); } catch (MessagingException | MailException | java.io.UnsupportedEncodingException e) { + log.error("이메일 발송 실패 - email: {}, error: {}", request.getEmail(), e.getMessage()); throw new BusinessException(AuthErrorStatus.SEND_VERIFICATION_EMAIL_FAILED); } } public void verifyEmailWithCode(CodeVerificationRequestDto request) { - String storedCode = executeRedisOperation(() -> + executeRedisOperationVoid(() -> { + if (rateLimitRepository.isAttemptExceeded(request.getEmail())) { + throw new BusinessException(AuthErrorStatus.VERIFICATION_ATTEMPT_EXCEEDED); + } + }); + + String storedCode = executeRedisOperation(() -> verificationCodeRepository.findByEmail(request.getEmail()).orElse(null) ); if (storedCode == null || !storedCode.equals(request.getVerificationCode())) { + executeRedisOperationVoid(() -> rateLimitRepository.incrementAttempt(request.getEmail())); throw new BusinessException(AuthErrorStatus.CODE_VERIFICATION_FAILED); } - + executeRedisOperationVoid(() -> { verificationCodeRepository.deleteByEmail(request.getEmail()); + rateLimitRepository.resetAttempts(request.getEmail()); }); } diff --git a/src/main/java/com/daramg/server/auth/exception/AuthErrorStatus.java b/src/main/java/com/daramg/server/auth/exception/AuthErrorStatus.java index a6bd042..16b8435 100644 --- a/src/main/java/com/daramg/server/auth/exception/AuthErrorStatus.java +++ b/src/main/java/com/daramg/server/auth/exception/AuthErrorStatus.java @@ -31,6 +31,9 @@ public enum AuthErrorStatus implements BaseErrorCode { INVALID_PASSWORD(HttpStatus.BAD_REQUEST, ErrorCategory.AUTH.generate(400_8), "비밀번호가 일치하지 않습니다."), UNSUPPORTED_EMAIL_PURPOSE(HttpStatus.BAD_REQUEST, ErrorCategory.AUTH.generate(400_9), "지원하지 않는 이메일 발송 목적입니다."), + EMAIL_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, ErrorCategory.AUTH.generate(429_1), "잠시 후 다시 시도해주세요. (1분에 1회만 요청 가능합니다.)"), + VERIFICATION_ATTEMPT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, ErrorCategory.AUTH.generate(429_2), "인증 시도 횟수를 초과했습니다. 새로운 인증번호를 요청해주세요."), + SEND_VERIFICATION_EMAIL_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCategory.AUTH.generate(500), "이메일 전송에 실패했습니다."), REDIS_CONNECTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCategory.AUTH.generate(500_1), "Redis 연결에 실패했습니다. 서버 관리자에게 문의 바랍니다."); diff --git a/src/main/java/com/daramg/server/auth/repository/RateLimitRepository.java b/src/main/java/com/daramg/server/auth/repository/RateLimitRepository.java new file mode 100644 index 0000000..d45c6af --- /dev/null +++ b/src/main/java/com/daramg/server/auth/repository/RateLimitRepository.java @@ -0,0 +1,48 @@ +package com.daramg.server.auth.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.time.Duration; + +@Repository +@RequiredArgsConstructor +public class RateLimitRepository { + + private final RedisTemplate redisTemplate; + + private static final String RATE_LIMIT_PREFIX = "ratelimit:"; + private static final String ATTEMPT_PREFIX = "attempts:"; + private static final Duration RATE_LIMIT_DURATION = Duration.ofMinutes(1); + private static final Duration ATTEMPT_DURATION = Duration.ofMinutes(3); + private static final int MAX_ATTEMPTS = 5; + + /** 1분에 1번 제한. 제한 초과 시 true 반환 */ + public boolean isRateLimited(String email) { + String key = RATE_LIMIT_PREFIX + email; + Boolean isNew = redisTemplate.opsForValue().setIfAbsent(key, "1", RATE_LIMIT_DURATION); + return !Boolean.TRUE.equals(isNew); + } + + /** 검증 시도 횟수 초과 여부. MAX_ATTEMPTS 이상이면 true 반환 */ + public boolean isAttemptExceeded(String email) { + String key = ATTEMPT_PREFIX + email; + String count = redisTemplate.opsForValue().get(key); + return count != null && Integer.parseInt(count) >= MAX_ATTEMPTS; + } + + /** 검증 실패 시 시도 횟수 증가 */ + public void incrementAttempt(String email) { + String key = ATTEMPT_PREFIX + email; + Long count = redisTemplate.opsForValue().increment(key); + if (count != null && count == 1) { + redisTemplate.expire(key, ATTEMPT_DURATION); + } + } + + /** 검증 성공 또는 새 코드 발급 시 시도 횟수 초기화 */ + public void resetAttempts(String email) { + redisTemplate.delete(ATTEMPT_PREFIX + email); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0239036..40b8610 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -20,7 +20,7 @@ spring: mail: smtp: auth: true - timeout: 5000 + timeout: 10000 starttls: enable: true servlet: From 801730c6af2c7b26a88b3e57f4cdd854b5f00cb6 Mon Sep 17 00:00:00 2001 From: Minjae Chung Date: Thu, 26 Feb 2026 17:17:38 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[feat]=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=A4=91=EB=B3=B5=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MailVerificationServiceImpl.java | 3 ++ .../auth/presentation/AuthControllerTest.java | 54 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/main/java/com/daramg/server/auth/application/MailVerificationServiceImpl.java b/src/main/java/com/daramg/server/auth/application/MailVerificationServiceImpl.java index 70576f0..78cc8f1 100644 --- a/src/main/java/com/daramg/server/auth/application/MailVerificationServiceImpl.java +++ b/src/main/java/com/daramg/server/auth/application/MailVerificationServiceImpl.java @@ -43,6 +43,9 @@ public void sendVerificationEmail(EmailVerificationRequestDto request) { } private void sendForSignup(EmailVerificationRequestDto request) { + if (userRepository.existsByEmail(request.getEmail())) { + throw new BusinessException(AuthErrorStatus.DUPLICATE_EMAIL); + } sendVerificationCode(request); } diff --git a/src/test/java/com/daramg/server/auth/presentation/AuthControllerTest.java b/src/test/java/com/daramg/server/auth/presentation/AuthControllerTest.java index e1d2ce7..fa257f8 100644 --- a/src/test/java/com/daramg/server/auth/presentation/AuthControllerTest.java +++ b/src/test/java/com/daramg/server/auth/presentation/AuthControllerTest.java @@ -21,7 +21,10 @@ import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import com.daramg.server.auth.exception.AuthErrorStatus; +import com.daramg.server.common.exception.BusinessException; import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; @@ -139,6 +142,40 @@ public class AuthControllerTest extends ControllerTestSupport { )); } + @Test + void 이미_가입된_이메일로_회원가입_인증코드_발송_실패() throws Exception { + // given + EmailVerificationRequestDto request = new EmailVerificationRequestDto(null, "daramg123@gmail.com", EmailPurpose.SIGNUP); + + doThrow(new BusinessException(AuthErrorStatus.DUPLICATE_EMAIL)) + .when(mailVerificationService).sendVerificationEmail(any(EmailVerificationRequestDto.class)); + + // when + ResultActions result = mockMvc.perform(post("/auth/email-verifications") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isConflict()); + } + + @Test + void 레이트_리밋_초과_시_인증코드_메일_발송_실패() throws Exception { + // given + EmailVerificationRequestDto request = new EmailVerificationRequestDto(null, "daramg123@gmail.com", EmailPurpose.SIGNUP); + + doThrow(new BusinessException(AuthErrorStatus.EMAIL_RATE_LIMIT_EXCEEDED)) + .when(mailVerificationService).sendVerificationEmail(any(EmailVerificationRequestDto.class)); + + // when + ResultActions result = mockMvc.perform(post("/auth/email-verifications") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isTooManyRequests()); + } + @Test void 인증번호로_이메일_인증() throws Exception { // given @@ -167,6 +204,23 @@ public class AuthControllerTest extends ControllerTestSupport { )); } + @Test + void 검증_시도_횟수_초과_시_이메일_인증_실패() throws Exception { + // given + CodeVerificationRequestDto request = new CodeVerificationRequestDto("daramg123@gmail.com", "123456"); + + doThrow(new BusinessException(AuthErrorStatus.VERIFICATION_ATTEMPT_EXCEEDED)) + .when(mailVerificationService).verifyEmailWithCode(any(CodeVerificationRequestDto.class)); + + // when + ResultActions result = mockMvc.perform(post("/auth/verify-email") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isTooManyRequests()); + } + @Test void 회원가입() throws Exception { // given From 45f35136a5c00c0db3c36255471115abca191670 Mon Sep 17 00:00:00 2001 From: Minjae Chung Date: Thu, 26 Feb 2026 17:33:06 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[fix]=20=EC=83=88=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B0=9C=EA=B8=89=20=EC=8B=9C=20=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=ED=9A=9F=EC=88=98=20=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=B7=A8=EC=95=BD=EC=A0=90=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../MailVerificationServiceImpl.java | 7 +- .../MailVerificationServiceImplTest.java | 150 ++++++++++++++++++ 2 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/daramg/server/auth/application/MailVerificationServiceImplTest.java diff --git a/src/main/java/com/daramg/server/auth/application/MailVerificationServiceImpl.java b/src/main/java/com/daramg/server/auth/application/MailVerificationServiceImpl.java index 78cc8f1..15b7752 100644 --- a/src/main/java/com/daramg/server/auth/application/MailVerificationServiceImpl.java +++ b/src/main/java/com/daramg/server/auth/application/MailVerificationServiceImpl.java @@ -73,10 +73,9 @@ private void sendVerificationCode(EmailVerificationRequestDto request) { String verificationCode = VerificationCodeGenerator.generate(); - executeRedisOperationVoid(() -> { - verificationCodeRepository.save(request.getEmail(), verificationCode); - rateLimitRepository.resetAttempts(request.getEmail()); - }); + executeRedisOperationVoid(() -> + verificationCodeRepository.save(request.getEmail(), verificationCode) + ); try { String htmlContent = mailContentBuilder.buildVerificationEmail(verificationCode); diff --git a/src/test/java/com/daramg/server/auth/application/MailVerificationServiceImplTest.java b/src/test/java/com/daramg/server/auth/application/MailVerificationServiceImplTest.java new file mode 100644 index 0000000..b3bd52c --- /dev/null +++ b/src/test/java/com/daramg/server/auth/application/MailVerificationServiceImplTest.java @@ -0,0 +1,150 @@ +package com.daramg.server.auth.application; + +import com.daramg.server.auth.domain.EmailPurpose; +import com.daramg.server.auth.dto.EmailVerificationRequestDto; +import com.daramg.server.auth.exception.AuthErrorStatus; +import com.daramg.server.auth.repository.RateLimitRepository; +import com.daramg.server.auth.repository.VerificationCodeRepository; +import com.daramg.server.auth.util.MailContentBuilder; +import com.daramg.server.auth.util.MimeMessageGenerator; +import com.daramg.server.common.exception.BusinessException; +import com.daramg.server.user.repository.UserRepository; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.javamail.JavaMailSender; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MailVerificationServiceImplTest { + + @Mock private MimeMessageGenerator mimeMessageGenerator; + @Mock private MailContentBuilder mailContentBuilder; + @Mock private JavaMailSender javaMailSender; + @Mock private VerificationCodeRepository verificationCodeRepository; + @Mock private RateLimitRepository rateLimitRepository; + @Mock private UserRepository userRepository; + + @InjectMocks + private MailVerificationServiceImpl mailVerificationService; + + private static final String TEST_EMAIL = "test@daramg.com"; + + @Nested + @DisplayName("인증코드 발송 시") + class SendVerificationEmail { + + @Test + @DisplayName("새 코드 발급 시 시도 횟수를 초기화하지 않는다") + void 새_코드_발급_시_시도_횟수_초기화_안됨() throws Exception { + given(userRepository.existsByEmail(TEST_EMAIL)).willReturn(false); + given(rateLimitRepository.isRateLimited(TEST_EMAIL)).willReturn(false); + given(mailContentBuilder.buildVerificationEmail(anyString())).willReturn("code"); + given(mimeMessageGenerator.generate(anyString(), anyString(), anyString())) + .willReturn(mock(MimeMessage.class)); + + EmailVerificationRequestDto request = new EmailVerificationRequestDto(null, TEST_EMAIL, EmailPurpose.SIGNUP); + mailVerificationService.sendVerificationEmail(request); + + verify(rateLimitRepository, never()).resetAttempts(TEST_EMAIL); + } + + @Test + @DisplayName("레이트 리밋 초과 시 예외가 발생한다") + void 레이트_리밋_초과_시_예외_발생() { + given(userRepository.existsByEmail(TEST_EMAIL)).willReturn(false); + given(rateLimitRepository.isRateLimited(TEST_EMAIL)).willReturn(true); + + EmailVerificationRequestDto request = new EmailVerificationRequestDto(null, TEST_EMAIL, EmailPurpose.SIGNUP); + + assertThatThrownBy(() -> mailVerificationService.sendVerificationEmail(request)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", AuthErrorStatus.EMAIL_RATE_LIMIT_EXCEEDED); + } + + @Test + @DisplayName("이미 가입된 이메일로 SIGNUP 요청 시 예외가 발생한다") + void 중복_이메일_SIGNUP_예외_발생() { + given(userRepository.existsByEmail(TEST_EMAIL)).willReturn(true); + + EmailVerificationRequestDto request = new EmailVerificationRequestDto(null, TEST_EMAIL, EmailPurpose.SIGNUP); + + assertThatThrownBy(() -> mailVerificationService.sendVerificationEmail(request)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", AuthErrorStatus.DUPLICATE_EMAIL); + } + + @Test + @DisplayName("미가입 이메일로 PASSWORD_RESET 요청 시 예외가 발생한다") + void 미가입_이메일_PASSWORD_RESET_예외_발생() { + given(userRepository.existsByEmail(TEST_EMAIL)).willReturn(false); + + EmailVerificationRequestDto request = new EmailVerificationRequestDto(null, TEST_EMAIL, EmailPurpose.PASSWORD_RESET); + + assertThatThrownBy(() -> mailVerificationService.sendVerificationEmail(request)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", AuthErrorStatus.EMAIL_NOT_REGISTERED); + } + } + + @Nested + @DisplayName("인증코드 검증 시") + class VerifyEmailWithCode { + + @Test + @DisplayName("인증 성공 시 시도 횟수를 초기화한다") + void 인증_성공_시_시도_횟수_초기화() { + given(rateLimitRepository.isAttemptExceeded(TEST_EMAIL)).willReturn(false); + given(verificationCodeRepository.findByEmail(TEST_EMAIL)).willReturn(Optional.of("123456")); + doNothing().when(verificationCodeRepository).deleteByEmail(TEST_EMAIL); + doNothing().when(rateLimitRepository).resetAttempts(TEST_EMAIL); + + com.daramg.server.auth.dto.CodeVerificationRequestDto request = + new com.daramg.server.auth.dto.CodeVerificationRequestDto(TEST_EMAIL, "123456"); + mailVerificationService.verifyEmailWithCode(request); + + verify(rateLimitRepository).resetAttempts(TEST_EMAIL); + } + + @Test + @DisplayName("검증 시도 횟수 초과 시 예외가 발생한다") + void 시도_횟수_초과_시_예외_발생() { + given(rateLimitRepository.isAttemptExceeded(TEST_EMAIL)).willReturn(true); + + com.daramg.server.auth.dto.CodeVerificationRequestDto request = + new com.daramg.server.auth.dto.CodeVerificationRequestDto(TEST_EMAIL, "123456"); + + assertThatThrownBy(() -> mailVerificationService.verifyEmailWithCode(request)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", AuthErrorStatus.VERIFICATION_ATTEMPT_EXCEEDED); + } + + @Test + @DisplayName("잘못된 인증코드 입력 시 시도 횟수가 증가한다") + void 틀린_코드_시도_횟수_증가() { + given(rateLimitRepository.isAttemptExceeded(TEST_EMAIL)).willReturn(false); + given(verificationCodeRepository.findByEmail(TEST_EMAIL)).willReturn(Optional.of("123456")); + + com.daramg.server.auth.dto.CodeVerificationRequestDto request = + new com.daramg.server.auth.dto.CodeVerificationRequestDto(TEST_EMAIL, "999999"); + + assertThatThrownBy(() -> mailVerificationService.verifyEmailWithCode(request)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", AuthErrorStatus.CODE_VERIFICATION_FAILED); + + verify(rateLimitRepository).incrementAttempt(TEST_EMAIL); + } + } +}