[Django] 로그인 시도 횟수 제한

Yangchef·2023년 9월 15일
1

Django

목록 보기
1/1
post-thumbnail

문제 상황

산보실록 프로젝트의 사용자 피드백을 받던 중,,,

브루트포스 공격에 취약할 거 같다는 피드백을 받아, 브루트 포스 공격 방지를 위해 로그인 시도 횟수 제한 기능을 추가하게 되었다.


해결방안 설계

우선, 구상한 해결 방안은 5회 이상 로그인 시도 실패 시 30분 동안 로그인 시도를 잠그는 기능이었다.

이 기능을 구현하기 위해 고민할 부분은 다음과 같았다.

  1. 로그인 전의 사용자를 어떻게 식별할 것인가 ?
  2. 로그인 시도 실패 횟수를 어디에 저장할 것인가 ?
  3. 일정 시간동안 로그인 시도를 막는 기능은 어떻게 구현할 것인가 ?

1차 설계

우선, 로그인 전의 사용자는 토큰으로 식별할 수 없으니 client IP를 통해 식별하기로 하였다.

다음으로, 로그인 시도 실패 횟수 저장의 경우, 별도 테이블을 만들어 IP column과 login_fail column을 만들어 저장해야겠다고 생각했다.

그리고 일정 시간 로그인 시도를 막는 기능은 datetime모듈을 이용해 5번째 로그인 시도 시각에 30분을 더해 저장하고, 이후 로그인 시도를 받을 때마다 저장한 시각과 현재 시각을 비교하여 불일치 하는 경우에는 에러를 raise하는 방식으로 구상했었다.


2차설계

1차설계에서는 시간 제한까지 구현하려면 새로운 테이블을 생성하고 3개의 column을 만들어 값을 저장해야 했다.

하지만, 메인 기능도 아닌데 DB에 새로 테이블을 만들어 resource를 낭비하기 싫었다.

그래서 간단한 값을 저장하는 방법을 고민하던 중, cache를 이용한다면 data 저장에 대한 고민과 시간 제한에 대한 고민 모두 해결할 수 있겠다는 생각이 들었다.

게다가 로그인 시도 횟수 제한 기능의 특성 상, 데이터에 잦은 접근이 필요하므로 cache를 활용하면 성능적으로도 이득을 볼 수 있었다.

  1. cache는 key-value 쌍으로 data를 저장할 수 있기 때문에, IP와 login_fail을 묶어서 저장하면 적은 resource로 값을 저장하고,
  2. cache 만료 기간을 설정하면 시간 제한도 별도 로직 없이 걸어줄 수 있고,
  3. cache 모듈을 이용하면 API를 작성하기도 훨씬 간단해지기 때문이다.
  4. 또한, 반복적인 DB 쿼리를 피해 성능 향상에도 도움이 될 수 있다.

따라서 이번 프로젝트에서는 IP와 로그인 시도 횟수는 cache에 저장하고, cache의 만료 기간 지정 옵션을 이용해 시간 제한 기능을 구현하는 것으로 계획을 변경했다.


구현

우선, settings.py에 Cache 설정을 추가 해준다.

'BACKEND'는 Django에서 지원하는 다양한 cache backend 중 적합한 하나를 고르면 된다.

이번 프로젝트에서는 일정 시간동안 로그인 시도를 막는 기능을 구현하기 위해 캐시 만료 기능이 있고, Django와 호환성이 높아 cache와의 상호작용이 용이한 django.core.cache.backends.db.DatabaseCache를 이용하기로 했다.

Django에서는 보다 다양한 종류의 cache backend를 지원하기 때문에 상황에 따라 적절한 cache backend를 골라 사용하면 될 것 같다.

'LOCATION'은 새로 생성될 cache 테이블의 이름을 임의로 정해주면 된다.

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.db.DatabaseCache',
        'LOCATION': 'sanbo_cache',
    }
}

다음으로, 사용자 IP를 가져오는 메서드를 작성한다.

우선, client의 IP를 구할 수 있는 Header들을 찾아보니, 다음 2가지가 있었다.

  1. Remote_ADDR

    Remot_ADDR Header는 TCP/IP 접속 시 발생하는 값으로 client의 IP를 담고 있으나, request가 다른 Proxy를 거쳐올 때마다 해당 값이 Proxy의 IP로 변하는 특징이 있었다.


  2. X_Forward_For

    X_Forward_For Header는 HTTP Header로 client부터 시작해 해당 request가 거쳐온 Proxy의 IP들이 차례로 저장된 값이었다.

따라서 먼저 X_Forwarded_For 값의 가장 첫번째 값을 가져오되, 만약 Proxy를 거치지 않은 경우 X_Forward_For 값이 존재하지 않기 때문에 이때는 Remote_ADDR 값을 통해 client IP를 구하도록 메서드를 작성한다.


def get_client_ip(self, request):
            x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
            if x_forwarded_for:
                ip = x_forwarded_for.split(',')[0]
            else:
                ip = request.META.get('REMOTE_ADDR')
            return ip 

위에서 설정한 cache 테이블과 클라이언트 IP를 가져오는 메서드를 이용해 로그인 시도 횟수 제한 로직을 구현한다.

이때, django에서 cache와 상호작용하기 위한 다양한 메서드들을 제공하는 django.core.cache.cache를 import해 사용했다.

  1. cache.get(key, default=None)

    cache에 저장된 값을 key를 통해 가져오는 메서드로, 만약 해당 key가 존재하지 않으면 default값을 대신 가져온다.


  2. cache.set(key, value, timeout=None)

    입력한 key, value값을 cache에 저장하고 해당 값의 만료 시간을 지정할 수 있다.


  3. cache.delete(key)

    key에 해당하는 값을 삭제한다.

from django.core.cache import cache

def post(self, request):
             
        # 사용자의 IP 주소 가져오기
        user_ip = self.get_client_ip(request)
        
        # 로그인 시도 횟수 가져오기
        login_attempts = cache.get(user_ip, 0)

        if login_attempts >= self.MAX_LOGIN_ATTEMPTS:
            # 로그인 시도 횟수가 제한을 초과한 경우
            return JsonResponse(
                {"message": "로그인 시도 횟수 초과. 잠시 후 다시 시도하세요."},
                status=status.HTTP_429_TOO_MANY_REQUESTS
            )
        
        if serializer.is_valid(raise_exception=False):        			  
        	# 로그인 성공 시 캐시 값 삭제
            cache.delete(user_ip)               
            
            return JsonResponse(
                {"message": "로그인 성공"},
                status = status.HTTP_200_OK 
            )
        else:
        
        	# 로그인 실패 시 시도횟수 +1 후 캐시에 저장
            login_attempts += 1
            cache.set(user_ip, login_attempts, timeout=1800)
            return JsonResponse(
                {"message": "로그인 실패"},
                status = status.HTTP_400_BAD_REQUEST 
            )

설정을 마친 후 다음 명령어 실행을 통해 sanbo_cache라는 cache공간을 생성한다.
py manage.py createcachetable

만약 위 명령어 실행 후, 아무 변화가 없다면 cache공간이 생성되지 않았을 가능성이 높다.

이런 경우에는 아래 쿼리문을 직접 입력해 cache공간을 만들어 주자.

CREATE TABLE `sanbo_cache` (
    `cache_key` varchar(255) NOT NULL PRIMARY KEY,
    `value` INTEGER,
    `expires` datetime
);

개선점

  1. 새롭게 생긴 보안 문제

    X_Forward_For Header의 값을 이용해 사용자의 IP를 구했기 때문에, 만약 악의적인 client가 X_Forward_For Header의 값을 임의로 위조하여 request를 보낸다면, client의 IP값이 올바른 값인지 보장할 수 없다,,,

    따라서 악의적으로 특정 client의 IP를 Header에 넣어 잘못된 로그인 시도를 계속 한다면, 해당 User가 로그인을 하지 못하도록 할 수 있고, IP를 계속 바꿔가며 브루트 포스 공격을 할 수도 있을 것 같다,,,

Reference

Django’s cache framework | Django documentation

0개의 댓글