Refactors throttle to use Django caching.

This commit is contained in:
Johnny 2020-10-13 20:27:05 +00:00
parent 7032d907a7
commit 2bd2a649ca
2 changed files with 80 additions and 21 deletions

View file

@ -52,10 +52,10 @@ _MUDINFO_CHANNEL = None
# Create throttles for too many account-creations and login attempts
CREATION_THROTTLE = Throttle(
limit=settings.CREATION_THROTTLE_LIMIT, timeout=settings.CREATION_THROTTLE_TIMEOUT
name='creation', limit=settings.CREATION_THROTTLE_LIMIT, timeout=settings.CREATION_THROTTLE_TIMEOUT
)
LOGIN_THROTTLE = Throttle(
limit=settings.LOGIN_THROTTLE_LIMIT, timeout=settings.LOGIN_THROTTLE_TIMEOUT
name='authentication', limit=settings.LOGIN_THROTTLE_LIMIT, timeout=settings.LOGIN_THROTTLE_TIMEOUT
)

View file

@ -1,4 +1,5 @@
from collections import defaultdict, deque
from django.core.cache import caches
from collections import deque
from evennia.utils import logger
import time
@ -12,8 +13,8 @@ class Throttle(object):
This version of the throttle is usable by both the terminal server as well
as the web server, imposes limits on memory consumption by using deques
with length limits instead of open-ended lists, and removes sparse keys when
no recent failures have been recorded.
with length limits instead of open-ended lists, and uses native Django
caches for automatic key eviction and persistence configurability.
"""
error_msg = "Too many failed attempts; you must wait a few minutes before trying again."
@ -23,6 +24,7 @@ class Throttle(object):
Allows setting of throttle parameters.
Keyword Args:
name (str): Name of this throttle.
limit (int): Max number of failures before imposing limiter
timeout (int): number of timeout seconds after
max number of tries has been reached.
@ -30,9 +32,37 @@ class Throttle(object):
rolling window; this is NOT the same as the limit after which
the throttle is imposed!
"""
self.storage = defaultdict(deque)
self.cache_size = self.limit = kwargs.get("limit", 5)
try:
self.storage = caches['throttle']
except Exception as e:
logger.log_err(f'Throttle: {e}')
self.storage = caches['default']
self.name = kwargs.get('name', 'ip-throttle')
self.limit = kwargs.get("limit", 5)
self.cache_size = kwargs.get('cache_size', self.limit)
self.timeout = kwargs.get("timeout", 5 * 60)
def get_cache_key(self, *args, **kwargs):
"""
Creates a 'prefixed' key containing arbitrary terms to prevent key
collisions in the same namespace.
"""
return '-'.join((self.name, *args))
def touch(self, key, *args, **kwargs):
"""
Refreshes the timeout on a given key and ensures it is recorded in the
key register.
Args:
key(str): Key of entry to renew.
"""
cache_key = self.get_cache_key(key)
if self.storage.touch(cache_key, self.timeout):
self.record_key(key)
def get(self, ip=None):
"""
@ -50,9 +80,18 @@ class Throttle(object):
"""
if ip:
return self.storage.get(ip, deque(maxlen=self.cache_size))
cache_key = self.get_cache_key(str(ip))
return self.storage.get(cache_key, deque(maxlen=self.limit))
else:
return self.storage
keys_key = self.get_cache_key('keys')
keys = self.storage.get_or_set(keys_key, set(), self.timeout)
data = self.storage.get_many((self.get_cache_key(x) for x in keys))
found_keys = set(data.keys())
if len(keys) != len(found_keys):
self.storage.set(keys_key, found_keys, self.timeout)
return data
def update(self, ip, failmsg="Exceeded threshold."):
"""
@ -67,24 +106,41 @@ class Throttle(object):
None
"""
cache_key = self.get_cache_key(ip)
# Get current status
previously_throttled = self.check(ip)
# Enforce length limits
if not self.storage[ip].maxlen:
self.storage[ip] = deque(maxlen=self.cache_size)
self.storage[ip].append(time.time())
# Get previous failures, if any
entries = self.storage.get(cache_key, [])
entries.append(time.time())
# Store updated record
self.storage.set(cache_key, deque(entries, maxlen=self.limit), self.timeout)
# See if this update caused a change in status
currently_throttled = self.check(ip)
# If this makes it engage, log a single activation event
if not previously_throttled and currently_throttled:
logger.log_sec(
"Throttle Activated: %s (IP: %s, %i hits in %i seconds.)"
% (failmsg, ip, self.limit, self.timeout)
)
logger.log_sec(f"Throttle Activated: {failmsg} (IP: {ip}, {self.limit} hits in {self.timeout} seconds.)")
self.record_key(ip)
def record_key(self, key, *args, **kwargs):
"""
Tracks keys as they are added to the cache (since there is no way to
get a list of keys after-the-fact).
Args:
key(str): Key being added to cache. This should be the original
key, not the cache-prefixed version.
"""
keys_key = self.get_cache_key('keys')
keys = self.storage.get(keys_key, set())
keys.add(key)
self.storage.set(keys_key, keys, self.timeout)
def check(self, ip):
"""
@ -102,17 +158,20 @@ class Throttle(object):
"""
now = time.time()
ip = str(ip)
cache_key = self.get_cache_key(ip)
# checking mode
latest_fails = self.storage[ip]
latest_fails = self.storage.get(cache_key)
if latest_fails and len(latest_fails) >= self.limit:
# too many fails recently
if now - latest_fails[-1] < self.timeout:
# too soon - timeout in play
self.touch(cache_key)
return True
else:
# timeout has passed. clear faillist
del self.storage[ip]
self.storage.delete(cache_key)
return False
else:
return False
return False