Add Throttling for users auth, register and detail
parent
1790cec37d
commit
9bf325d5f9
|
@ -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",
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"))
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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)
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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"]
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
Loading…
Reference in New Issue