본문 바로가기
💻 뚝딱뚝딱/북북클럽

[개발일지 #015] 이메일 인증 실패 시도 횟수 제한 기능

by 뚜루리 2025. 4. 25.
728x90
320x100

🎯 오늘의 목표

  • 이메일 인증 실패 시도 횟수 제한 기능

⚙️ 진행한 작업

  • 이메일 인증 실패 시도 횟수 제한 기능

🛠️ 개발내용

  • 보안과 남용 방지를 위해 필요한 기능으로, 일반적으로는 Redis 같은 인메모리 저장소를 활용해서 이메일 주소별로 제한 시간 동안 실패 횟수를 카운트하고, 기준 초과 시 인증 요청을 차단하는 방식으로 구현한다고 하여 해보기로 함.
구성 요소 설명
Redis Key email:fail:<email> 형태로 저장
TTL(Time to Live) 일정 시간 후 자동 삭제 (예: 10분)
허용 실패 횟수 예: 5회
차단 메시지 "인증 요청이 너무 많습니다. 잠시 후 다시 시도해주세요."

 

📌 예외 클래스 추가

EmailVerificationLimitExceededException.java

@Getter
public class EmailVerificationLimitExceededException extends RuntimeException {
    private final ErrorCode errorCode;

    public EmailVerificationLimitExceededException() {
        super(ErrorCode.EMAIL_VERIFICATION_TOO_MANY_ATTEMPTS.getMessage());
        this.errorCode = ErrorCode.EMAIL_VERIFICATION_TOO_MANY_ATTEMPTS;
    }
}

nvalidTokenException.java

@Getter
public class InvalidTokenException extends RuntimeException {
    private final ErrorCode errorCode;

    public InvalidTokenException() {
        super(ErrorCode.INVALID_EMAIL_VERIFICATION_TOKEN.getMessage());
        this.errorCode = ErrorCode.INVALID_EMAIL_VERIFICATION_TOKEN;
    }
}

 

📌 ErrorCode에 항목 추가 (ErrorCode.java)

 EMAIL_VERIFICATION_TOO_MANY_ATTEMPTS("TOO_MANY_ATTEMPTS", "이메일 인증 시도 횟수를 초과했습니다. 잠시 후 다시 시도해주세요."),
 INVALID_EMAIL_VERIFICATION_TOKEN("INVALID_EMAIL_VERIFICATION_TOKEN", "인증 토큰이 유효하지 않습니다."),

 

📌 EmailVerificationService.java 수정

@Service
@RequiredArgsConstructor
public class EmailVerificationService {

    private final RedisTemplate<String, String> redisTemplate;
    private final JavaMailSender mailSender;
    private final EmailVerificationRepository emailVerificationRepository;

    private static final int MAX_FAIL_COUNT = 5;
    private static final long FAIL_EXPIRE_MINUTES = 10;

    public void sendVerificationEmail(String email) {
        String token = UUID.randomUUID().toString();
        String redisKey = "email:verify:" + token;

        redisTemplate.opsForValue().set(redisKey, email, 10, TimeUnit.MINUTES);

        String link = "http://localhost:8080/api/email/verify?token=" + token;

        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(email);
        message.setSubject("[북북클럽] 이메일 인증");
        message.setText("이메일 인증을 위해 아래 링크를 클릭해주세요:\n" + link);

        mailSender.send(message);
    }

    public boolean verifyEmail(String token) {
        String redisKey = "email:verify:" + token;
        String email = redisTemplate.opsForValue().get(redisKey);

        if (email == null) {
            throw new InvalidTokenException(); // 유효하지 않은 토큰 예외
        }

        checkFailLimit(email); // 실패 횟수 체크
        redisTemplate.delete(redisKey); // 사용한 토큰 제거

        Optional<EmailVerification> optional = emailVerificationRepository.findByEmail(email);

        EmailVerification verification = optional
                .map(existing -> {
                    existing.markAsVerified();
                    return existing;
                })
                .orElseGet(() -> EmailVerification.builder()
                        .email(email)
                        .verified(true)
                        .verifiedAt(LocalDateTime.now())
                        .build());

        verification.markAsVerified();
        emailVerificationRepository.save(verification);

        resetFailCount(email); // 성공 시 실패 카운트 초기화
        return true;
    }

    public boolean isEmailVerified(String email) {
        return emailVerificationRepository.existsByEmailAndVerifiedIsTrue(email);
    }

    private void checkFailLimit(String email) {
        String failKey = "email:fail:" + email;
        Long count = redisTemplate.opsForValue().increment(failKey);

        if (count == 1) {
            redisTemplate.expire(failKey, FAIL_EXPIRE_MINUTES, TimeUnit.MINUTES);
        }

        if (count >= MAX_FAIL_COUNT) {
            throw new EmailVerificationLimitExceededException();
        }
    }

    private void resetFailCount(String email) {
        String failKey = "email:fail:" + email;
        redisTemplate.delete(failKey);
    }
}

 

 

📌 GlobalExceptionHandler.java 수정

package ddururi.bookbookclub.global.exception;

import ddururi.bookbookclub.global.common.ApiResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

	//생략//

    // ✅ 추가: 유효하지 않은 이메일 인증 토큰
    @ExceptionHandler(InvalidTokenException.class)
    public ResponseEntity<ApiResponse<Void>> handleInvalidToken(InvalidTokenException e) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(ApiResponse.fail(e.getErrorCode()));
    }

    // ✅ 추가: 이메일 인증 시도 횟수 초과
    @ExceptionHandler(EmailVerificationLimitExceededException.class)
    public ResponseEntity<ApiResponse<Void>> handleEmailVerificationLimit(EmailVerificationLimitExceededException e) {
        return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) // 429
                .body(ApiResponse.fail(e.getErrorCode()));
    }

}
728x90
320x100