From 2bd2a649ca5ec616be14047cd1b95a4e4a137e8f Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 13 Oct 2020 20:27:05 +0000 Subject: [PATCH] Refactors throttle to use Django caching. --- evennia/accounts/accounts.py | 4 +- evennia/server/throttle.py | 97 +++++++++++++++++++++++++++++------- 2 files changed, 80 insertions(+), 21 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 8bf324c898..3861a118b4 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -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 ) diff --git a/evennia/server/throttle.py b/evennia/server/throttle.py index 4a22c03113..a38c7cedcc 100644 --- a/evennia/server/throttle.py +++ b/evennia/server/throttle.py @@ -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 \ No newline at end of file