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,
"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",

View File

@ -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,
}

View File

@ -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"))

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")
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.

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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):

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)
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.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()