From 8dfcbe4006417cb7787c7c75318de353890c4c06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Tue, 28 Feb 2017 16:56:07 +0100 Subject: [PATCH] Added common throttle for all the API --- settings/common.py | 10 +- settings/testing.py | 6 +- taiga/base/api/settings.py | 1 + taiga/base/throttling.py | 131 +++++++++++++ tests/integration/test_throwttling.py | 33 ++-- tests/unit/test_common_throttle.py | 265 ++++++++++++++++++++++++++ 6 files changed, 423 insertions(+), 23 deletions(-) create mode 100644 tests/unit/test_common_throttle.py diff --git a/settings/common.py b/settings/common.py index f5c02101..ed6b3939 100644 --- a/settings/common.py +++ b/settings/common.py @@ -433,12 +433,13 @@ REST_FRAMEWORK = { "taiga.external_apps.auth_backends.Token", ), "DEFAULT_THROTTLE_CLASSES": ( - "taiga.base.throttling.AnonRateThrottle", - "taiga.base.throttling.UserRateThrottle" + "taiga.base.throttling.CommonThrottle", ), "DEFAULT_THROTTLE_RATES": { - "anon": None, - "user": None, + "anon-write": None, + "user-write": None, + "anon-read": None, + "user-read": None, "import-mode": None, "import-dump-mode": "1/minute", "create-memberships": None, @@ -446,6 +447,7 @@ REST_FRAMEWORK = { "register-success": None, "user-detail": None, }, + "DEFAULT_THROTTLE_WHITELIST": [], "FILTER_BACKEND": "taiga.base.filters.FilterBackend", "EXCEPTION_HANDLER": "taiga.base.exceptions.exception_handler", "PAGINATE_BY": 30, diff --git a/settings/testing.py b/settings/testing.py index 13e2b434..e3f565e2 100644 --- a/settings/testing.py +++ b/settings/testing.py @@ -29,8 +29,10 @@ INSTALLED_APPS = INSTALLED_APPS + [ ] REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = { - "anon": None, - "user": None, + "anon-write": None, + "anon-read": None, + "user-write": None, + "user-read": None, "import-mode": None, "import-dump-mode": None, "create-memberships": None, diff --git a/taiga/base/api/settings.py b/taiga/base/api/settings.py index 680e0390..afd1c33b 100644 --- a/taiga/base/api/settings.py +++ b/taiga/base/api/settings.py @@ -106,6 +106,7 @@ DEFAULTS = { "user": None, "anon": None, }, + "DEFAULT_THROTTLE_WHITELIST": [], # Pagination "PAGINATE_BY": None, diff --git a/taiga/base/throttling.py b/taiga/base/throttling.py index b5a4903d..329c7441 100644 --- a/taiga/base/throttling.py +++ b/taiga/base/throttling.py @@ -17,6 +17,7 @@ # along with this program. If not, see . from taiga.base.api import throttling +from django.conf import settings class GlobalThrottlingMixin: @@ -57,5 +58,135 @@ class UserRateThrottle(throttling.UserRateThrottle): scope = "user" +class CommonThrottle(throttling.SimpleRateThrottle): + cache_format = "throtte_%(scope)s_%(rate)s_%(ident)s" + + def __init__(self): + pass + + def has_to_finalize(self, request, response, view): + return False + + def allow_request(self, request, view): + scope = self.get_scope(request) + ident = self.get_ident(request) + rates = self.get_rates(scope) + if ident in settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST']: + return True + + if rates is None or rates == []: + return True + + now = self.timer() + + waits = [] + history_writes = [] + + for rate in rates: + rate_name = rate[0] + rate_num_requests = rate[1] + rate_duration = rate[2] + + key = self.get_cache_key(ident, scope, rate_name) + history = self.cache.get(key, []) + + while history and history[-1] <= now - rate_duration: + history.pop() + + if len(history) >= rate_num_requests: + waits.append(self.wait_time(history, rate, now)) + + history_writes.append({ + "key": key, + "history": history, + "rate_duration": rate_duration, + }) + + if waits: + self._wait = max(waits) + return False + + for history_write in history_writes: + history_write['history'].insert(0, now) + self.cache.set( + history_write['key'], + history_write['history'], + history_write['rate_duration'] + ) + return True + + def get_rates(self, scope): + try: + rates = self.THROTTLE_RATES[scope] + except KeyError: + msg = "No default throttle rate set for \"%s\" scope" % scope + raise ImproperlyConfigured(msg) + + if rates is None: + return [] + elif isinstance(rates, str): + return [self.parse_rate(rates)] + elif isinstance(rates, list): + return list(map(self.parse_rate, rates)) + else: + msg = "No valid throttle rate set for \"%s\" scope" % scope + raise ImproperlyConfigured(msg) + + def parse_rate(self, rate): + """ + Given the request rate string, return a two tuple of: + , + """ + if rate is None: + return None + num, period = rate.split("/") + num_requests = int(num) + duration = {"s": 1, "m": 60, "h": 3600, "d": 86400}[period[0]] + return (rate, num_requests, duration) + + def get_scope(self, request): + if request.user.is_authenticated(): + if request.method in ["POST", "PUT", "PATCH", "DELETE"]: + scope = "user-write" + else: + scope = "user-read" + else: + if request.method in ["POST", "PUT", "PATCH", "DELETE"]: + scope = "anon-write" + else: + scope = "anon-read" + return scope + + def get_ident(self, request): + if request.user.is_authenticated(): + ident = request.user.id + else: + ident = request.META.get("HTTP_X_FORWARDED_FOR") + if ident is None: + ident = request.META.get("REMOTE_ADDR") + return ident + + def get_cache_key(self, ident, scope, rate): + return self.cache_format % { "scope": scope, "ident": ident, "rate": rate } + + def wait_time(self, history, rate, now): + rate_num_requests = rate[1] + rate_duration = rate[2] + + if history: + remaining_duration = rate_duration - (now - history[-1]) + else: + remaining_duration = rate_duration + + available_requests = rate_num_requests - len(history) + 1 + if available_requests <= 0: + return remaining_duration + + return remaining_duration / float(available_requests) + + def wait(self): + return self._wait + + class SimpleRateThrottle(throttling.SimpleRateThrottle): pass diff --git a/tests/integration/test_throwttling.py b/tests/integration/test_throwttling.py index 8f9b7976..0f96bb10 100644 --- a/tests/integration/test_throwttling.py +++ b/tests/integration/test_throwttling.py @@ -29,20 +29,15 @@ from .. import factories as f pytestmark = pytest.mark.django_db -anon_rate_path = "taiga.base.throttling.AnonRateThrottle.get_rate" -user_rate_path = "taiga.base.throttling.UserRateThrottle.get_rate" import_rate_path = "taiga.export_import.throttling.ImportModeRateThrottle.get_rate" def test_anonimous_throttling_policy(client, settings): f.create_project() url = reverse("projects-list") + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = "2/min" - with mock.patch(anon_rate_path) as anon_rate, \ - mock.patch(user_rate_path) as user_rate, \ - mock.patch(import_rate_path) as import_rate: - anon_rate.return_value = "2/day" - user_rate.return_value = "4/day" + with mock.patch(import_rate_path) as import_rate: import_rate.return_value = "7/day" cache.clear() @@ -53,19 +48,19 @@ def test_anonimous_throttling_policy(client, settings): response = client.json.get(url) assert response.status_code == 429 + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = None + cache.clear() + def test_user_throttling_policy(client, settings): project = f.create_project() f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) url = reverse("projects-detail", kwargs={"pk": project.pk}) + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = "4/min" client.login(project.owner) - with mock.patch(anon_rate_path) as anon_rate, \ - mock.patch(user_rate_path) as user_rate, \ - mock.patch(import_rate_path) as import_rate: - anon_rate.return_value = "2/day" - user_rate.return_value = "4/day" + with mock.patch(import_rate_path) as import_rate: import_rate.return_value = "7/day" cache.clear() @@ -81,6 +76,8 @@ def test_user_throttling_policy(client, settings): assert response.status_code == 429 client.logout() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = None + cache.clear() def test_import_mode_throttling_policy(client, settings): @@ -95,14 +92,12 @@ def test_import_mode_throttling_policy(client, settings): data = { "subject": "Test" } + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = "2/min" + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = "4/min" client.login(project.owner) - with mock.patch(anon_rate_path) as anon_rate, \ - mock.patch(user_rate_path) as user_rate, \ - mock.patch(import_rate_path) as import_rate: - anon_rate.return_value = "2/day" - user_rate.return_value = "4/day" + with mock.patch(import_rate_path) as import_rate: import_rate.return_value = "7/day" cache.clear() @@ -124,3 +119,7 @@ def test_import_mode_throttling_policy(client, settings): assert response.status_code == 429 client.logout() + + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = None + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = None + cache.clear() diff --git a/tests/unit/test_common_throttle.py b/tests/unit/test_common_throttle.py new file mode 100644 index 00000000..f5586f4d --- /dev/null +++ b/tests/unit/test_common_throttle.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2017 Andrey Antukh +# Copyright (C) 2014-2017 Jesús Espino +# Copyright (C) 2014-2017 David Barragán +# Copyright (C) 2014-2017 Alejandro Alonso +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.test import RequestFactory +from django.core.cache import cache +from django.contrib.auth.models import AnonymousUser + +from taiga.base.throttling import CommonThrottle +from taiga.users.models import User + + +def test_user_no_write_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-write'] = None + request = rf.post("/test") + request.user = User(id=1) + throttling = CommonThrottle() + for x in range(100): + assert throttling.allow_request(request, None) + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-write'] = None + +def test_user_simple_write_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-write'] = "1/min" + request = rf.post("/test") + request.user = User(id=1) + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-write'] = None + +def test_user_multi_write_first_small_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-write'] = ["1/min", "10/min"] + request = rf.post("/test") + request.user = User(id=1) + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-write'] = None + +def test_user_multi_write_first_big_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-write'] = ["10/min", "1/min"] + request = rf.post("/test") + request.user = User(id=1) + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-write'] = None + +def test_user_no_read_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = None + request = rf.get("/test") + request.user = User(id=1) + throttling = CommonThrottle() + for x in range(100): + assert throttling.allow_request(request, None) + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = None + +def test_user_simple_read_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = "1/min" + request = rf.get("/test") + request.user = User(id=1) + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = None + +def test_user_multi_read_first_small_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = ["1/min", "10/min"] + request = rf.get("/test") + request.user = User(id=1) + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = None + +def test_user_multi_read_first_big_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = ["10/min", "1/min"] + request = rf.get("/test") + request.user = User(id=1) + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = None + +def test_whitelisted_user_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = "1/min" + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST'] = [1] + request = rf.get("/test") + request.user = User(id=1) + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = None + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST'] = [] + +def test_not_whitelisted_user_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = "1/min" + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST'] = [1] + request = rf.get("/test") + request.user = User(id=2) + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = None + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST'] = [] + +def test_anon_no_write_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-write'] = None + request = rf.post("/test") + request.user = AnonymousUser() + throttling = CommonThrottle() + for x in range(100): + assert throttling.allow_request(request, None) + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-write'] = None + +def test_anon_simple_write_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-write'] = "1/min" + request = rf.post("/test") + request.user = AnonymousUser() + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-write'] = None + +def test_anon_multi_write_first_small_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-write'] = ["1/min", "10/min"] + request = rf.post("/test") + request.user = AnonymousUser() + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-write'] = None + +def test_anon_multi_write_first_big_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-write'] = ["10/min", "1/min"] + request = rf.post("/test") + request.user = AnonymousUser() + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-write'] = None + +def test_anon_no_read_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = None + request = rf.get("/test") + request.user = AnonymousUser() + throttling = CommonThrottle() + for x in range(100): + assert throttling.allow_request(request, None) + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = None + +def test_anon_simple_read_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = "1/min" + request = rf.get("/test") + request.user = AnonymousUser() + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = None + +def test_anon_multi_read_first_small_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = ["1/min", "10/min"] + request = rf.get("/test") + request.user = AnonymousUser() + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = None + +def test_anon_multi_read_first_big_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = ["10/min", "1/min"] + request = rf.get("/test") + request.user = AnonymousUser() + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = None + +def test_whitelisted_anon_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = "1/min" + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST'] = ["127.0.0.1"] + request = rf.get("/test") + request.user = AnonymousUser() + request.META["REMOTE_ADDR"] = "127.0.0.1" + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) + for x in range(100): + assert throttling.allow_request(request, None) + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = None + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST'] = [] + +def test_not_whitelisted_anon_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = "1/min" + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST'] = ["127.0.0.1"] + request = rf.get("/test") + request.user = AnonymousUser() + request.META["REMOTE_ADDR"] = "127.0.0.2" + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = None + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST'] = []