Add Throttling for users auth, register and detail

remotes/origin/github-import
Jesús Espino 2017-01-05 13:14:09 +01:00 committed by Alejandro
parent 1790cec37d
commit 9bf325d5f9
12 changed files with 261 additions and 16 deletions

View File

@ -440,7 +440,10 @@ REST_FRAMEWORK = {
"user": None, "user": None,
"import-mode": None, "import-mode": None,
"import-dump-mode": "1/minute", "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", "FILTER_BACKEND": "taiga.base.filters.FilterBackend",
"EXCEPTION_HANDLER": "taiga.base.exceptions.exception_handler", "EXCEPTION_HANDLER": "taiga.base.exceptions.exception_handler",

View File

@ -34,4 +34,7 @@ REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {
"import-mode": None, "import-mode": None,
"import-dump-mode": None, "import-dump-mode": None,
"create-memberships": None, "create-memberships": None,
"login-fail": None,
"register-success": None,
"user-detail": None,
} }

View File

@ -39,6 +39,7 @@ from .services import get_auth_plugins
from .services import accept_invitation_by_existing_user from .services import accept_invitation_by_existing_user
from .permissions import AuthPermission from .permissions import AuthPermission
from .throttling import LoginFailRateThrottle, RegisterSuccessRateThrottle
def _parse_data(data:dict, *, cls): def _parse_data(data:dict, *, cls):
@ -66,6 +67,7 @@ parse_private_register_data = partial(_parse_data, cls=PrivateRegisterValidator)
class AuthViewSet(viewsets.ViewSet): class AuthViewSet(viewsets.ViewSet):
permission_classes = (AuthPermission,) permission_classes = (AuthPermission,)
throttle_classes = (LoginFailRateThrottle, RegisterSuccessRateThrottle)
def _public_register(self, request): def _public_register(self, request):
if not settings.PUBLIC_REGISTER_ENABLED: if not settings.PUBLIC_REGISTER_ENABLED:
@ -112,5 +114,4 @@ class AuthViewSet(viewsets.ViewSet):
accept_invitation_by_existing_user(invitation_token, data['id']) accept_invitation_by_existing_user(invitation_token, data['id'])
return response.Ok(data) return response.Ok(data)
raise exc.BadRequest(_("invalid login type")) raise exc.BadRequest(_("invalid login type"))

46
taiga/auth/throttling.py Normal file
View File

@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# 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 <http://www.gnu.org/licenses/>.
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)

View File

@ -64,6 +64,12 @@ class BaseThrottle(object):
""" """
raise NotImplementedError(".allow_request() must be overridden") 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): def wait(self):
""" """
Optionally, return a recommended number of seconds to wait before 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") 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): def get_rate(self):
""" """
Determine the string representation of the allowed request rate. Determine the string representation of the allowed request rate.

View File

@ -144,6 +144,8 @@ class APIView(View):
# Allow dependancy injection of other settings to make testing easier. # Allow dependancy injection of other settings to make testing easier.
settings = api_settings settings = api_settings
_trottle_instances = None
@classmethod @classmethod
def as_view(cls, **initkwargs): def as_view(cls, **initkwargs):
""" """
@ -286,7 +288,9 @@ class APIView(View):
""" """
Instantiates and returns the list of throttles that this view uses. 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): def get_content_negotiator(self):
""" """
@ -342,6 +346,15 @@ class APIView(View):
if not throttle.allow_request(request, self): if not throttle.allow_request(request, self):
self.throttled(request, throttle.wait()) 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 # Dispatch methods
def initialize_request(self, request, *args, **kwargs): def initialize_request(self, request, *args, **kwargs):
@ -391,6 +404,8 @@ class APIView(View):
for key, value in self.headers.items(): for key, value in self.headers.items():
response[key] = value response[key] = value
self.finalize_throttles(request, response)
return response return response
def handle_exception(self, exc): def handle_exception(self, exc):

View File

@ -19,21 +19,43 @@
from taiga.base.api import throttling from taiga.base.api import throttling
class AnonRateThrottle(throttling.AnonRateThrottle): class GlobalThrottlingMixin:
scope = "anon" """
throttled_methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] 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): def allow_request(self, request, view):
if request.method not in self.throttled_methods: if view.action in self.throttled_actions:
return True
return super().allow_request(request, view) return super().allow_request(request, view)
return True
class AnonRateThrottle(throttling.AnonRateThrottle):
scope = "anon"
class UserRateThrottle(throttling.UserRateThrottle): class UserRateThrottle(throttling.UserRateThrottle):
scope = "user" scope = "user"
throttled_methods = ["GET", "POST", "PUT", "DELETE", "PATCH"]
def allow_request(self, request, view):
if request.method not in self.throttled_methods: class SimpleRateThrottle(throttling.SimpleRateThrottle):
return True pass
return super().allow_request(request, view)

View File

@ -19,9 +19,9 @@
from taiga.base import throttling from taiga.base import throttling
class MembershipsRateThrottle(throttling.UserRateThrottle): class MembershipsRateThrottle(throttling.ThrottleByActionMixin, throttling.UserRateThrottle):
scope = "create-memberships" scope = "create-memberships"
throttled_methods = ["POST", "PUT"] throttled_actions = ["create", "resend_invitation", "bulk_create"]
def exceeded_throttling_restriction(self, request, view): def exceeded_throttling_restriction(self, request, view):
self.created_memberships = 0 self.created_memberships = 0

View File

@ -49,6 +49,7 @@ from . import services
from . import utils as user_utils from . import utils as user_utils
from .signals import user_cancel_account as user_cancel_account_signal from .signals import user_cancel_account as user_cancel_account_signal
from .signals import user_change_email as user_change_email_signal from .signals import user_change_email as user_change_email_signal
from .throttling import UserDetailRateThrottle
class UsersViewSet(ModelCrudViewSet): class UsersViewSet(ModelCrudViewSet):
permission_classes = (permissions.UserPermission,) permission_classes = (permissions.UserPermission,)
@ -57,6 +58,7 @@ class UsersViewSet(ModelCrudViewSet):
admin_validator_class = validators.UserAdminValidator admin_validator_class = validators.UserAdminValidator
validator_class = validators.UserValidator validator_class = validators.UserValidator
filter_backends = (MembersFilterBackend,) filter_backends = (MembersFilterBackend,)
throttle_classes = (UserDetailRateThrottle,)
model = models.User model = models.User
def get_serializer_class(self): def get_serializer_class(self):

24
taiga/users/throttling.py Normal file
View File

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# 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 <http://www.gnu.org/licenses/>.
from taiga.base import throttling
class UserDetailRateThrottle(throttling.GlobalThrottlingMixin, throttling.ThrottleByActionMixin, throttling.SimpleRateThrottle):
scope = "user-detail"
throttled_actions = ["by_username", "retrieve"]

View File

@ -184,3 +184,83 @@ def test_auth_uppercase_ignore(client, settings):
response = client.post(reverse("auth-list"), login_form) response = client.post(reverse("auth-list"), login_form)
assert response.status_code == 400 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

View File

@ -24,6 +24,7 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core.files import File from django.core.files import File
from django.core.cache import cache as default_cache
from .. import factories as f from .. import factories as f
from ..utils import DUMMY_BMP_DATA 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.anon_permissions = ["view_project", "view_epic", "view_us", "view_tasks", "view_issues"]
project.save() project.save()
assert len(get_voted_list(fav_user, viewer_unpriviliged_user)) == 4 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()