Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -41,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);
}

Expand All @@ -60,12 +65,18 @@ private void sendForEmailChange(EmailVerificationRequestDto request) {
}

private void sendVerificationCode(EmailVerificationRequestDto request) {
String verificationCode = VerificationCodeGenerator.generate();

executeRedisOperationVoid(() -> {
verificationCodeRepository.save(request.getEmail(), verificationCode);
if (rateLimitRepository.isRateLimited(request.getEmail())) {
throw new BusinessException(AuthErrorStatus.EMAIL_RATE_LIMIT_EXCEEDED);
}
});


String verificationCode = VerificationCodeGenerator.generate();

executeRedisOperationVoid(() ->
verificationCodeRepository.save(request.getEmail(), verificationCode)
);

try {
String htmlContent = mailContentBuilder.buildVerificationEmail(verificationCode);
MimeMessage mimeMessage = mimeMessageGenerator.generate(
Expand All @@ -76,21 +87,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());
});
Comment on lines +96 to 114

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

verifyEmailWithCode 메서드에서 여러 번의 executeRedisOperationVoidexecuteRedisOperation 호출이 있습니다. 이는 각 호출마다 Redis 연결 및 예외 처리 오버헤드를 발생시켜 비효율적입니다. 관련된 모든 Redis 작업을 단일 executeRedisOperationVoid 블록으로 묶으면 코드가 더 간결해지고 성능도 향상됩니다.

        executeRedisOperationVoid(() -> {
            if (rateLimitRepository.isAttemptExceeded(request.getEmail())) {
                throw new BusinessException(AuthErrorStatus.VERIFICATION_ATTEMPT_EXCEEDED);
            }

            String storedCode = verificationCodeRepository.findByEmail(request.getEmail()).orElse(null);

            if (storedCode == null || !storedCode.equals(request.getVerificationCode())) {
                rateLimitRepository.incrementAttempt(request.getEmail());
                throw new BusinessException(AuthErrorStatus.CODE_VERIFICATION_FAILED);
            }

            verificationCodeRepository.deleteByEmail(request.getEmail());
            rateLimitRepository.resetAttempts(request.getEmail());
        });

}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 연결에 실패했습니다. 서버 관리자에게 문의 바랍니다.");

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> 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;
}
Comment on lines +29 to +33

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

isAttemptExceeded 메서드에서 Redis로부터 가져온 count 값을 Integer.parseInt()로 변환하고 있습니다. 만약 Redis에 저장된 값이 숫자가 아닌 경우 NumberFormatException이 발생하여 서버 오류(500)로 이어질 수 있습니다. 안정성을 높이기 위해 try-catch 블록을 사용하여 예외를 처리하는 것이 좋습니다. 예외 발생 시 에러를 기록하고, 손상된 키를 삭제하여 문제를 해결할 수 있습니다.

Suggested change
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 boolean isAttemptExceeded(String email) {
String key = ATTEMPT_PREFIX + email;
String count = redisTemplate.opsForValue().get(key);
if (count == null) {
return false;
}
try {
return Integer.parseInt(count) >= MAX_ATTEMPTS;
} catch (NumberFormatException e) {
// Consider adding a logger to record this unexpected event.
redisTemplate.delete(key);
return false;
}
}


/** 검증 실패 시 시도 횟수 증가 */
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);
}
}
2 changes: 1 addition & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ spring:
mail:
smtp:
auth: true
timeout: 5000
timeout: 10000
starttls:
enable: true
servlet:
Expand Down
Original file line number Diff line number Diff line change
@@ -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("<html>code</html>");
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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading