diff --git a/settings/common.py b/settings/common.py index 8bd85f62..6980efb8 100644 --- a/settings/common.py +++ b/settings/common.py @@ -440,7 +440,10 @@ REST_FRAMEWORK = { "user": None, "import-mode": None, "import-dump-mode": "1/minute", - "create-memberships": None + "create-memberships": None, + "login-fail": None, + "register-success": None, + "user-detail": None, }, "FILTER_BACKEND": "taiga.base.filters.FilterBackend", "EXCEPTION_HANDLER": "taiga.base.exceptions.exception_handler", diff --git a/settings/testing.py b/settings/testing.py index c8875026..12532b9b 100644 --- a/settings/testing.py +++ b/settings/testing.py @@ -34,4 +34,7 @@ REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = { "import-mode": None, "import-dump-mode": None, "create-memberships": None, + "login-fail": None, + "register-success": None, + "user-detail": None, } diff --git a/taiga/auth/api.py b/taiga/auth/api.py index 52892bb1..d9a2c85c 100644 --- a/taiga/auth/api.py +++ b/taiga/auth/api.py @@ -39,6 +39,7 @@ from .services import get_auth_plugins from .services import accept_invitation_by_existing_user from .permissions import AuthPermission +from .throttling import LoginFailRateThrottle, RegisterSuccessRateThrottle def _parse_data(data:dict, *, cls): @@ -66,6 +67,7 @@ parse_private_register_data = partial(_parse_data, cls=PrivateRegisterValidator) class AuthViewSet(viewsets.ViewSet): permission_classes = (AuthPermission,) + throttle_classes = (LoginFailRateThrottle, RegisterSuccessRateThrottle) def _public_register(self, request): if not settings.PUBLIC_REGISTER_ENABLED: @@ -112,5 +114,4 @@ class AuthViewSet(viewsets.ViewSet): accept_invitation_by_existing_user(invitation_token, data['id']) return response.Ok(data) - raise exc.BadRequest(_("invalid login type")) diff --git a/taiga/auth/throttling.py b/taiga/auth/throttling.py new file mode 100644 index 00000000..e622407b --- /dev/null +++ b/taiga/auth/throttling.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 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 taiga.base import throttling + + +class LoginFailRateThrottle(throttling.GlobalThrottlingMixin, throttling.ThrottleByActionMixin, throttling.SimpleRateThrottle): + scope = "login-fail" + throttled_actions = ["create"] + + def throttle_success(self, request, view): + return True + + def finalize(self, request, response, view): + if response.status_code == 400: + self.history.insert(0, self.now) + self.cache.set(self.key, self.history, self.duration) + + +class RegisterSuccessRateThrottle(throttling.GlobalThrottlingMixin, throttling.ThrottleByActionMixin, throttling.SimpleRateThrottle): + scope = "register-success" + throttled_actions = ["register"] + + def throttle_success(self, request, view): + return True + + def finalize(self, request, response, view): + if response.status_code == 201: + self.history.insert(0, self.now) + self.cache.set(self.key, self.history, self.duration) + diff --git a/taiga/base/api/throttling.py b/taiga/base/api/throttling.py index 74178922..f1b49ca0 100644 --- a/taiga/base/api/throttling.py +++ b/taiga/base/api/throttling.py @@ -64,6 +64,12 @@ class BaseThrottle(object): """ raise NotImplementedError(".allow_request() must be overridden") + def finalize(self, request, response, view): + """ + Optionally, update the Trottling information based on de response. + """ + return None + def wait(self): """ Optionally, return a recommended number of seconds to wait before @@ -105,6 +111,12 @@ class SimpleRateThrottle(BaseThrottle): """ raise NotImplementedError(".get_cache_key() must be overridden") + def has_to_finalize(self, request, response, view): + """ + Determine if the finalize method must be executed. + """ + return self.rate is not None + def get_rate(self): """ Determine the string representation of the allowed request rate. diff --git a/taiga/base/api/views.py b/taiga/base/api/views.py index 8c6dfd09..310f424a 100644 --- a/taiga/base/api/views.py +++ b/taiga/base/api/views.py @@ -144,6 +144,8 @@ class APIView(View): # Allow dependancy injection of other settings to make testing easier. settings = api_settings + _trottle_instances = None + @classmethod def as_view(cls, **initkwargs): """ @@ -286,7 +288,9 @@ class APIView(View): """ Instantiates and returns the list of throttles that this view uses. """ - return [throttle() for throttle in self.throttle_classes] + if self._trottle_instances is None: + self._trottle_instances = [throttle() for throttle in self.throttle_classes] + return self._trottle_instances def get_content_negotiator(self): """ @@ -342,6 +346,15 @@ class APIView(View): if not throttle.allow_request(request, self): self.throttled(request, throttle.wait()) + def finalize_throttles(self, request, response): + """ + Check if request should be throttled. + Raises an appropriate exception if the request is throttled. + """ + for throttle in self.get_throttles(): + if throttle.has_to_finalize(request, response, self): + throttle.finalize(request, response, self) + # Dispatch methods def initialize_request(self, request, *args, **kwargs): @@ -391,6 +404,8 @@ class APIView(View): for key, value in self.headers.items(): response[key] = value + self.finalize_throttles(request, response) + return response def handle_exception(self, exc): diff --git a/taiga/base/throttling.py b/taiga/base/throttling.py index f2484ea9..676d6027 100644 --- a/taiga/base/throttling.py +++ b/taiga/base/throttling.py @@ -19,21 +19,43 @@ from taiga.base.api import throttling -class AnonRateThrottle(throttling.AnonRateThrottle): - scope = "anon" - throttled_methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] +class GlobalThrottlingMixin: + """ + Define the cache key based on the user IP independently if the user is + logged in or not. + """ + def get_cache_key(self, request, view): + ident = request.META.get("HTTP_X_FORWARDED_FOR") + if ident is None: + ident = request.META.get("REMOTE_ADDR") + + return self.cache_format % { + "scope": self.scope, + "ident": ident + } + + +class ThrottleByActionMixin: + throttled_actions = [] + + def has_to_finalize(self, request, response, view): + if super().has_to_finalize(request, response, view): + return view.action in self.throttled_actions + return False def allow_request(self, request, view): - if request.method not in self.throttled_methods: - return True - return super().allow_request(request, view) + if view.action in self.throttled_actions: + return super().allow_request(request, view) + return True + + +class AnonRateThrottle(throttling.AnonRateThrottle): + scope = "anon" class UserRateThrottle(throttling.UserRateThrottle): scope = "user" - throttled_methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] - def allow_request(self, request, view): - if request.method not in self.throttled_methods: - return True - return super().allow_request(request, view) + +class SimpleRateThrottle(throttling.SimpleRateThrottle): + pass diff --git a/taiga/projects/throttling.py b/taiga/projects/throttling.py index 7694b274..f46844f3 100644 --- a/taiga/projects/throttling.py +++ b/taiga/projects/throttling.py @@ -19,9 +19,9 @@ from taiga.base import throttling -class MembershipsRateThrottle(throttling.UserRateThrottle): +class MembershipsRateThrottle(throttling.ThrottleByActionMixin, throttling.UserRateThrottle): scope = "create-memberships" - throttled_methods = ["POST", "PUT"] + throttled_actions = ["create", "resend_invitation", "bulk_create"] def exceeded_throttling_restriction(self, request, view): self.created_memberships = 0 diff --git a/taiga/users/api.py b/taiga/users/api.py index ef22c661..d08ebeb9 100644 --- a/taiga/users/api.py +++ b/taiga/users/api.py @@ -49,6 +49,7 @@ from . import services from . import utils as user_utils from .signals import user_cancel_account as user_cancel_account_signal from .signals import user_change_email as user_change_email_signal +from .throttling import UserDetailRateThrottle class UsersViewSet(ModelCrudViewSet): permission_classes = (permissions.UserPermission,) @@ -57,6 +58,7 @@ class UsersViewSet(ModelCrudViewSet): admin_validator_class = validators.UserAdminValidator validator_class = validators.UserValidator filter_backends = (MembersFilterBackend,) + throttle_classes = (UserDetailRateThrottle,) model = models.User def get_serializer_class(self): diff --git a/taiga/users/throttling.py b/taiga/users/throttling.py new file mode 100644 index 00000000..9a5f4918 --- /dev/null +++ b/taiga/users/throttling.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 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 taiga.base import throttling + + +class UserDetailRateThrottle(throttling.GlobalThrottlingMixin, throttling.ThrottleByActionMixin, throttling.SimpleRateThrottle): + scope = "user-detail" + throttled_actions = ["by_username", "retrieve"] diff --git a/tests/integration/test_auth_api.py b/tests/integration/test_auth_api.py index 1add6615..bc3bd3b2 100644 --- a/tests/integration/test_auth_api.py +++ b/tests/integration/test_auth_api.py @@ -184,3 +184,83 @@ def test_auth_uppercase_ignore(client, settings): response = client.post(reverse("auth-list"), login_form) assert response.status_code == 400 + + +def test_login_fail_throttling(client, settings): + settings.PUBLIC_REGISTER_ENABLED = True + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["login-fail"] = "1/minute" + + register_form = {"username": "valid_username_login_fail", + "password": "valid_password", + "full_name": "fullname", + "email": "valid_username_login_fail@email.com", + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + + login_form = {"type": "normal", + "username": "valid_username_login_fail", + "password": "valid_password"} + + response = client.post(reverse("auth-list"), login_form) + assert response.status_code == 200 + + login_form = {"type": "normal", + "username": "invalid_username_login_fail", + "password": "invalid_password"} + + response = client.post(reverse("auth-list"), login_form) + assert response.status_code == 400 + + login_form = {"type": "normal", + "username": "invalid_username_login_fail", + "password": "invalid_password"} + + response = client.post(reverse("auth-list"), login_form) + assert response.status_code == 429 + + login_form = {"type": "normal", + "username": "valid_username_login_fail", + "password": "valid_password"} + + response = client.post(reverse("auth-list"), login_form) + assert response.status_code == 429 + + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["login-fail"] = None + +def test_register_success_throttling(client, settings): + settings.PUBLIC_REGISTER_ENABLED = True + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["register-success"] = "1/minute" + + register_form = {"username": "valid_username_register_success", + "password": "valid_password", + "full_name": "fullname", + "email": "", + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 400 + + register_form = {"username": "valid_username_register_success", + "password": "valid_password", + "full_name": "fullname", + "email": "valid_username_register_success@email.com", + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 201 + + register_form = {"username": "valid_username_register_success2", + "password": "valid_password2", + "full_name": "fullname", + "email": "valid_username_register_success2@email.com", + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 429 + + register_form = {"username": "valid_username_register_success2", + "password": "valid_password2", + "full_name": "fullname", + "email": "", + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 429 + + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["register-success"] = None diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py index cad6507c..678d4a03 100644 --- a/tests/integration/test_users.py +++ b/tests/integration/test_users.py @@ -24,6 +24,7 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.urlresolvers import reverse from django.core.files import File +from django.core.cache import cache as default_cache from .. import factories as f from ..utils import DUMMY_BMP_DATA @@ -980,3 +981,39 @@ def test_get_voted_list_permissions(): project.anon_permissions = ["view_project", "view_epic", "view_us", "view_tasks", "view_issues"] project.save() assert len(get_voted_list(fav_user, viewer_unpriviliged_user)) == 4 + +############################## +## Retrieve user +############################## + +def test_users_retrieve_throttling_api(client): + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user-detail"] = "1/minute" + + user = f.UserFactory.create() + + url = reverse('users-detail', kwargs={"pk": user.pk}) + data = {} + + response = client.get(url, content_type="application/json") + assert response.status_code == 200 + + response = client.get(url, content_type="application/json") + assert response.status_code == 429 + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user-detail"] = None + default_cache.clear() + + +def test_users_by_username_throttling_api(client): + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user-detail"] = "1/minute" + user = f.UserFactory.create(username="test-user-detail") + + url = reverse('users-by-username') + data = {} + + response = client.get(url, {"username": user.username}, content_type="application/json") + assert response.status_code == 200 + + response = client.get(url, {"username": user.username}, content_type="application/json") + assert response.status_code == 429 + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user-detail"] = None + default_cache.clear()