본문 바로가기
팀 프로젝트/cheerha.project

Redis를 이용한 이상 사용자 차단 & 로깅 기능 구현

by pon9 2025. 2. 19.

1. 이메일 차단 기능

연속 5회 이상 로그인 실패 시 해당 이메일의 로그인을 차단하는 기능을 만들었다. ttl은 꽤 길게 잡았다

잘못된 비밀번호를 입력하면 사진처럼 이메일을 키로, 횟수를 밸류로 레디스에 저장한다.

ttl이 긴 만큼 로그인 성공 시 fail키는 사라진다.

이렇게 5번 잘못입력하면 blocked를 가진채로 저장되며

해당 이메일로 로그인 시도 시 차단된다.

 

이메일 차단 기능 코드

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class EmailBlockingAspect {

    @Around("execution(* 로그인 컨트롤러 위치(..))")
    public Object blockAbnormalEmail(ProceedingJoinPoint joinPoint) throws Throwable {
        //차단된 이메일인지 확인
        if (Boolean.TRUE.equals(redisTemplate.hasKey(redisKey))) {
            log.warn("임시차단된 사용자의 로그인 요청: {}", email);
            redisTemplate.opsForValue().set(redisKey, "blocked", EMAIL_BLOCK_DURATION, TimeUnit.MINUTES);
            throw new UnAuthorizedException(AuthErrorCode.BLOCKED_EMAIL);
        }

        try {
            Object result = joinPoint.proceed(args);
            //로그인 성공 시 failCount 삭제
            redisTemplate.delete(failCountKey);
            return result;
        } catch (Exception e) {
            if(Objects.equals(e.getMessage(), "패스워드가 잘못되었습니다.")){
                log.error("로그인 실패: {}", email);

                //잘못된 비밀번호 입력 시 count 1회 추가, 첫 추가 시 ttl 설정
                long failedAttempts = redisTemplate.opsForValue().increment(failCountKey);
                if (failedAttempts == 1) {
                    redisTemplate.expire(failCountKey, EMAIL_FAIL_DURATION, TimeUnit.DAYS);
                }

                //잘못된 시도 5회 시 이메일 차단
                if (failedAttempts >= MAX_FAILED_COUNT) {
                    redisTemplate.opsForValue().set(redisKey, "blocked", EMAIL_BLOCK_DURATION, TimeUnit.MINUTES);
                    log.warn("임시차단된 이메일: {} 이 {} 분 간 차단되었습니다", email, EMAIL_BLOCK_DURATION);
                    redisTemplate.expire(failCountKey, EMAIL_FAIL_DURATION, TimeUnit.MINUTES);
                }
            }
            throw e;
        }
    }
}

(변수와 생성자 선언하는 부분 코드는 안 가져왔다)

로그인 컨트롤러메서드를 around로 감싸서 서비스에 넘어가기 전에 이메일을 차단하도록 했다

차단된 이메일인지 확인하고

1. 로그인 성공 시 failCount를 삭제

2. 로그인 실패 시("패스워드가 잘못되었습니다" 와 에러메세지가 같은 경우 << 근데 이 부분 보안 관점에서 괜찮은걸까 생각하게된다)

count를 1회 추가하며, 첫 추가(첫 카운트 갱신)시에 ttl을 설정한다

count가 일정 횟수를 넘기면 해당 이메일에 대한 로그인을 막는다.

 

확장가능성:

차단된 email은 db에서 관리하고, 이메일 인증을 통해 밴을 풀 수 있다.

관리자와 1대1 채팅을 통해 밴을 풀어달라고 조를 수도 있을 것이다.

관리자 페이지로 확장도 가능하다.

 

2. ip 차단 기능

로그인을 시도할 때 마다 사용자의 ip를 key로, 시도한 이메일을 list의 요소로 가지도록 구현했다.

이 값은 로그인에 성공해도 사라지지 않도록 했다. 성공해도 갱신되는 값이다

매 시도마다 ttl이 새로 갱신된다.

한 ip에서 로그인 시도한 이메일의 개수가 정해진 값을 초과하면 차단됐다는 로그와 함께, blocked ip로 등록된다.

로그인 시도 시 차단된다.

 

ip 차단 기능 코드

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class IpBlockingAspect {

    @Around("execution(* 로그인 컨트롤러 위치(..))")
    public Object blockAbnormalIp(ProceedingJoinPoint joinPoint) throws Throwable {
        HttpServletRequest request = getRequest();
        if (request == null) {
            return joinPoint.proceed();
        }

        //차단된 ip 인지 확인
        if (Boolean.TRUE.equals(redisTemplate.hasKey(redisBlockKey))) {
            log.warn("차단된 IP 로그인 시도: {}", ip);
            throw new UnAuthorizedException(AuthErrorCode.BANNED_IP);
        }

        try {
            return joinPoint.proceed();
        } catch (Exception e) {
            log.error("로그인 실패: IP={}, 이메일={}", ip, email);

            //해당 ip 에서 로그인 시도한 이메일 리스트 가져오기
            List<String> attemptedEmails = redisTemplate.opsForList().range(redisAttemptKey, 0, -1);
            if (attemptedEmails == null || !attemptedEmails.contains(email)) {
                redisTemplate.opsForList().rightPush(redisAttemptKey, email);
            }

            //추가 시 ttl 설정
            redisTemplate.expire(redisAttemptKey, ATTEMPT_TTL, TimeUnit.MINUTES);

            //서로 다른 이메일이 3개 이상이면 차단
            if (!Objects.requireNonNull(attemptedEmails).contains(email) && attemptedEmails.size() >= MAX_DIFFERENT_EMAILS) {
                redisTemplate.opsForValue().set(redisBlockKey, "blocked", IP_BLOCK_DURATION, TimeUnit.DAYS);
                log.warn("IP {} 차단됨 (서로 다른 {}개 이메일 감지됨)", ip, MAX_DIFFERENT_EMAILS + 1);
            }

            throw e;
        }
    }

    private HttpServletRequest getRequest() {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        return attributes != null ? attributes.getRequest() : null;
    }

    private String getClientIp(HttpServletRequest request) {
        if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
            ip = ip.split(",")[0].trim();
        } else {
            ip = request.getRemoteAddr();
        }
        if (ip.contains(":")) {
            log.info("IPv6 추출 성공: {}", ip);
        } else {
            log.info("IPv4 추출 성공: {}", ip);
        }
        return ip;
    }
}

(변수 생성자 선언하는 부분 코드는 안 가져왔다)

마찬가지로 컨트롤러를 감쌌다.

RequestContextHolder로 ip를 추출한다. 변별력을 위해 ipV6을 우선적으로 가져오고, 없다면 ipV4로 가져온다

ip를 key로, value를 리스트<이메일>로 해서 사용자의 로그인 시도를 감지해서, 서로 다른 이메일이 정해진 개수를 넘어가면 해당 ip를 차단한다.

 

확장가능성:

차단된 ip역시 mysql로 넘길 수 있다.

ip밴은 인터셉터를 활용해서 어플리케이션 전역에 활용하면 좋을거다. 구현해야지

 


좀 더 생각중인 기능:

이미 로그인된 사용자의 이메일로 로그인 하는 경우? token기반으로 어떻게 구현할 수 있을까

jwt payload 암호화