diff --git a/README.md b/README.md index aecc542917badbd777a5d07cd71c9b90d0d525f3..3325b3e654ebed703528482c43b4ae16f9a9a5df 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Please note that we refer to Debian packages here and **not** pip packages. - python3-qrcode - python3-fido2 (version 0.5.0 or 0.9.1, optional) - python3-prometheus-client (optional, needed for metrics) +- python3-redis (optional, needed for redis support) - python3-oauthlib - python3-flask-babel - python3-argon2 diff --git a/debian/control b/debian/control index 0e498d6a15f3d2fe548a96bc6204fcc630f7fc79..1c0c87561f45c60373183bbbd5f4c7bfda481b38 100644 --- a/debian/control +++ b/debian/control @@ -33,4 +33,5 @@ Recommends: nginx, python3-mysqldb, python3-prometheus-client, + python3-redis, Description: Web-based user management and single sign-on software diff --git a/tests/test_ratelimit.py b/tests/test_ratelimit.py index a10903f4055ecaa93c472d6c12fdb0333cd4e6e7..8965ff75da2c9037ebc7fb2d34a9048f1f691fba 100644 --- a/tests/test_ratelimit.py +++ b/tests/test_ratelimit.py @@ -6,7 +6,10 @@ from uffd.models.ratelimit import get_addrkey, format_delay, Ratelimit, Ratelimi from utils import UffdTestCase -class TestRatelimit(UffdTestCase): +class TestRatelimitDB(UffdTestCase): + def setUpConfig(self, config): + config['REDIS_HOST'] = False + def test_limiting(self): cases = [ (1*60, 3), @@ -48,3 +51,18 @@ class TestRatelimit(UffdTestCase): self.assertIsInstance(format_delay(120), str) self.assertIsInstance(format_delay(3600), str) self.assertIsInstance(format_delay(4000), str) + +class TestRatelimitRedis(TestRatelimitDB): + def setUpConfig(self, config): + import redis + config['REDIS_HOST'] = 'localhost' + config['REDIS_PORT'] = 6379 + config['REDIS_DB'] = 0 + self.redis = redis.Redis( + host=config['REDIS_HOST'], + port=config['REDIS_PORT'], + db=config['REDIS_DB']) + return config + + def setUpDB(self): + self.redis.flushdb(); diff --git a/tests/utils.py b/tests/utils.py index 3b98e64b9d8ac3dba8968338ccdd450ab3cc2ee2..491794b129fd9cb2282d2e63e1cb8b039039ef3f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -68,7 +68,7 @@ class UffdTestCase(unittest.TestCase): 'MAIL_SKIP_SEND': True, 'SELF_SIGNUP': True, } - + config = self.setUpConfig(config) self.app = create_app(config) self.setUpApp() self.client = self.app.test_client() @@ -92,6 +92,9 @@ class UffdTestCase(unittest.TestCase): self.setUpDB() db.session.commit() + def setUpConfig(self, config): + return config + def setUpApp(self): pass diff --git a/uffd/__init__.py b/uffd/__init__.py index ad186dee28e1e6223661add3014988f61c3e02f4..c6ee05372f3d8e179ef093a21c513b9272c3b33b 100644 --- a/uffd/__init__.py +++ b/uffd/__init__.py @@ -68,6 +68,7 @@ def create_app(test_config=None): # pylint: disable=too-many-locals,too-many-sta app.register_blueprint(csrf_bp) + models.init_app(app) views.init_app(app) commands.init_app(app) diff --git a/uffd/default_config.cfg b/uffd/default_config.cfg index 7b01157fb27e75cf264e21413a7f268c6c1f91c7..035afe4c6a070ff868ef95f397f97b7f27290cb6 100644 --- a/uffd/default_config.cfg +++ b/uffd/default_config.cfg @@ -41,6 +41,14 @@ MAIL_PASSWORD='*****' MAIL_USE_STARTTLS=True MAIL_FROM_ADDRESS='foo@bar.com' +# Optional Redis Server, used for rate limits if configured. +# Rate Limits fail back to in Database if no redis is configured. +# If you enable this, we depend on python3-redis + +#REDIS_HOST='localhost' +#REDIS_PORT=6379 +#REDIS_DB=0 + # Set to a domain name (e.g. "remailer.example.com") to enable remailer. # Requires special mail server configuration (see uffd-socketmapd). Can be # enabled/disabled per-service in the service settings. If enabled, services diff --git a/uffd/models/__init__.py b/uffd/models/__init__.py index 52d9709b285d4fcacaa837bae570e4af7c79d729..9aa5d3406261d163a7855bb9b92150855b592858 100644 --- a/uffd/models/__init__.py +++ b/uffd/models/__init__.py @@ -26,3 +26,6 @@ __all__ = [ 'User', 'UserEmail', 'Group', 'RatelimitEvent', 'Ratelimit', 'HostRatelimit', 'host_ratelimit', 'format_delay', ] + +def init_app(app): + Ratelimit.init_app(app) diff --git a/uffd/models/ratelimit.py b/uffd/models/ratelimit.py index cd370956b0e9fa980ff09e5e4d5faca614c83a52..fb9b9dca92d58b46c80cec65af52ef34253853c3 100644 --- a/uffd/models/ratelimit.py +++ b/uffd/models/ratelimit.py @@ -24,23 +24,36 @@ class RatelimitEvent(db.Model): return self.expires < datetime.datetime.utcnow() class Ratelimit: + _redis = False + def __init__(self, name, interval, limit): self.name = name self.interval = interval self.limit = limit self.base = interval**(1/limit) + @classmethod + def init_app(cls, app): + if not app.config.get('REDIS_HOST'): + cls._redis = False + else: + import redis + cls._redis = redis.Redis(host=app.config['REDIS_HOST'], port=app.config['REDIS_PORT'], db=app.config['REDIS_DB']) + + + def __redis_get_index(self, key=None): + return 'ratelimit:{}{}'.format(self.name, (':' + key) or '') + def log(self, key=None): - db.session.add(RatelimitEvent(name=self.name, key=key, expires=datetime.datetime.utcnow() + datetime.timedelta(seconds=self.interval))) - db.session.commit() + if not self._redis: + db.session.add(RatelimitEvent(name=self.name, key=key, expires=datetime.datetime.utcnow() + datetime.timedelta(seconds=self.interval))) + db.session.commit() + else: + self._redis.incr(self.__redis_get_index(key)) + self._redis.expire(self.__redis_get_index(key), ttl=self.intervall, nx=True) - def get_delay(self, key=None): - events = RatelimitEvent.query\ - .filter(db.not_(RatelimitEvent.expired))\ - .filter_by(name=self.name, key=key)\ - .order_by(RatelimitEvent.timestamp)\ - .all() - if not events: + def get_delay_backoff(self, events): + if events < 1: return 0 delay = math.ceil(self.base**len(events)) if delay < 5: @@ -49,6 +62,18 @@ class Ratelimit: remaining = events[0].timestamp + datetime.timedelta(seconds=delay) - datetime.datetime.utcnow() return max(0, math.ceil(remaining.total_seconds())) + def get_delay(self, key=None): + if not self._redis: + events = RatelimitEvent.query\ + .filter(db.not_(RatelimitEvent.expired))\ + .filter_by(name=self.name, key=key)\ + .order_by(RatelimitEvent.timestamp)\ + .all() + else: + events = self._redis.get(self.__redis_get_index(key)) or 0 + + return self.get_delay_backoff(len(events)) + def get_addrkey(addr=None): if addr is None: addr = request.remote_addr