산보실록 프로젝트의 사용자 피드백을 받던 중,,,
브루트포스 공격에 취약할 거 같다는 피드백을 받아, 브루트 포스 공격 방지를 위해 로그인 시도 횟수 제한 기능을 추가하게 되었다.
우선, 구상한 해결 방안은 5회 이상 로그인 시도 실패 시 30분 동안 로그인 시도를 잠그는 기능이었다.
이 기능을 구현하기 위해 고민할 부분은 다음과 같았다.
우선, 로그인 전의 사용자는 토큰으로 식별할 수 없으니 client IP를 통해 식별하기로 하였다.
다음으로, 로그인 시도 실패 횟수 저장의 경우, 별도 테이블을 만들어 IP column과 login_fail column을 만들어 저장해야겠다고 생각했다.
그리고 일정 시간 로그인 시도를 막는 기능은 datetime모듈을 이용해 5번째 로그인 시도 시각에 30분을 더해 저장하고, 이후 로그인 시도를 받을 때마다 저장한 시각과 현재 시각을 비교하여 불일치 하는 경우에는 에러를 raise하는 방식으로 구상했었다.
1차설계에서는 시간 제한까지 구현하려면 새로운 테이블을 생성하고 3개의 column을 만들어 값을 저장해야 했다.
하지만, 메인 기능도 아닌데 DB에 새로 테이블을 만들어 resource를 낭비하기 싫었다.
그래서 간단한 값을 저장하는 방법을 고민하던 중, cache를 이용한다면 data 저장에 대한 고민과 시간 제한에 대한 고민 모두 해결할 수 있겠다는 생각이 들었다.
게다가 로그인 시도 횟수 제한 기능의 특성 상, 데이터에 잦은 접근이 필요하므로 cache를 활용하면 성능적으로도 이득을 볼 수 있었다.
따라서 이번 프로젝트에서는 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',
}
}
우선, client의 IP를 구할 수 있는 Header들을 찾아보니, 다음 2가지가 있었다.
- Remote_ADDR
Remot_ADDR Header는 TCP/IP 접속 시 발생하는 값으로 client의 IP를 담고 있으나, request가 다른 Proxy를 거쳐올 때마다 해당 값이 Proxy의 IP로 변하는 특징이 있었다.
- 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
이때, django에서 cache와 상호작용하기 위한 다양한 메서드들을 제공하는 django.core.cache.cache를 import해 사용했다.
- cache.get(key, default=None)
cache에 저장된 값을 key를 통해 가져오는 메서드로, 만약 해당 key가 존재하지 않으면 default값을 대신 가져온다.
- cache.set(key, value, timeout=None)
입력한 key, value값을 cache에 저장하고 해당 값의 만료 시간을 지정할 수 있다.
- 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
)
py manage.py createcachetable
이런 경우에는 아래 쿼리문을 직접 입력해 cache공간을 만들어 주자.
CREATE TABLE `sanbo_cache` (
`cache_key` varchar(255) NOT NULL PRIMARY KEY,
`value` INTEGER,
`expires` datetime
);
X_Forward_For Header의 값을 이용해 사용자의 IP를 구했기 때문에, 만약 악의적인 client가 X_Forward_For Header의 값을 임의로 위조하여 request를 보낸다면, client의 IP값이 올바른 값인지 보장할 수 없다,,,
따라서 악의적으로 특정 client의 IP를 Header에 넣어 잘못된 로그인 시도를 계속 한다면, 해당 User가 로그인을 하지 못하도록 할 수 있고, IP를 계속 바꿔가며 브루트 포스 공격을 할 수도 있을 것 같다,,,