From 59bcbd881648d722726e2132b08597ac5376a60d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksi=20H=C3=A4kli?= Date: Wed, 16 Dec 2020 18:00:00 +0200 Subject: [PATCH] Move DRF integration into signals Add documentation on how to enable the integration and remove the logic from global middleware. Fixes #673 --- CHANGES.rst | 13 +++++-- axes/conf.py | 5 --- axes/middleware.py | 14 ------- axes/tests/test_middleware.py | 71 ----------------------------------- docs/6_integration.rst | 55 +++++++++++++-------------- 5 files changed, 37 insertions(+), 121 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 44ceff8..163438a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,6 +3,14 @@ Changes ======= +5.10.0 (2020-12-16) +------------------- + +- Deprecate stock DRF support, require users to set it up per project. + Check the documentation for more information. + [aleksihakli] + + 5.9.1 (2020-12-02) ------------------ @@ -17,9 +25,8 @@ Changes - Add Python 3.9 support. [hramezani] -- Prevent ``AccessAttempt`` creation with database handler - when username is not set - and ``AXES_ONLY_USER_FAILURES`` setting is not set. +- Prevent ``AccessAttempt`` creation with database handler when + username is not set and ``AXES_ONLY_USER_FAILURES`` setting is not set. [hramezani] diff --git a/axes/conf.py b/axes/conf.py index 352760e..3606f65 100644 --- a/axes/conf.py +++ b/axes/conf.py @@ -115,8 +115,3 @@ settings.AXES_META_PRECEDENCE_ORDER = getattr( "AXES_META_PRECEDENCE_ORDER", getattr(settings, "IPWARE_META_PRECEDENCE_ORDER", ("REMOTE_ADDR",)), ) - -# set to `True` if using with Django REST Framework -settings.AXES_REST_FRAMEWORK_ACTIVE = getattr( - settings, "AXES_REST_FRAMEWORK_ACTIVE", False -) diff --git a/axes/middleware.py b/axes/middleware.py index 16c5239..9605d07 100644 --- a/axes/middleware.py +++ b/axes/middleware.py @@ -43,20 +43,6 @@ class AxesMiddleware: response = self.get_response(request) if settings.AXES_ENABLED: - if "rest_framework" in settings.INSTALLED_APPS: - AxesProxyHandler.update_request(request) - username = get_client_username(request) - credentials = get_credentials(username) - failures_since_start = AxesProxyHandler.get_failures( - request, credentials - ) - if ( - settings.AXES_LOCK_OUT_AT_FAILURE - and failures_since_start >= get_failure_limit(request, credentials) - ): - - request.axes_locked_out = True - if getattr(request, "axes_locked_out", None): response = get_lockout_response(request) # type: ignore diff --git a/axes/tests/test_middleware.py b/axes/tests/test_middleware.py index ce89956..4a73aeb 100644 --- a/axes/tests/test_middleware.py +++ b/axes/tests/test_middleware.py @@ -39,74 +39,3 @@ class MiddlewareTestCase(AxesTestCase): response = AxesMiddleware(get_response)(self.request) self.assertEqual(response.status_code, self.STATUS_SUCCESS) - - @mock.patch("django.conf.settings.INSTALLED_APPS", ["rest_framework"]) - def test_response_contains_required_attrs_with_drf_integration(self): - def get_response(request): - return HttpResponse() - - self.assertFalse(hasattr(self.request, "axes_locked_out")) - self.assertFalse(hasattr(self.request, "axes_attempt_time")) - self.assertFalse(hasattr(self.request, "axes_ip_address")) - self.assertFalse(hasattr(self.request, "axes_user_agent")) - self.assertFalse(hasattr(self.request, "axes_path_info")) - self.assertFalse(hasattr(self.request, "axes_http_accept")) - self.assertFalse(hasattr(self.request, "axes_updated")) - - AxesMiddleware(get_response)(self.request) - - self.assertTrue(hasattr(self.request, "axes_locked_out")) - self.assertTrue(hasattr(self.request, "axes_attempt_time")) - self.assertTrue(hasattr(self.request, "axes_ip_address")) - self.assertTrue(hasattr(self.request, "axes_user_agent")) - self.assertTrue(hasattr(self.request, "axes_path_info")) - self.assertTrue(hasattr(self.request, "axes_http_accept")) - self.assertTrue(hasattr(self.request, "axes_updated")) - - def test_response_does_not_contain_extra_attrs_without_drf_integration( - self, - ): - def get_response(request): - return HttpResponse() - - self.assertNotIn("rest_framework", settings.INSTALLED_APPS) - - AxesMiddleware(get_response)(self.request) - - self.assertFalse(hasattr(self.request, "axes_locked_out")) - self.assertFalse(hasattr(self.request, "axes_attempt_time")) - self.assertFalse(hasattr(self.request, "axes_ip_address")) - self.assertFalse(hasattr(self.request, "axes_user_agent")) - self.assertFalse(hasattr(self.request, "axes_path_info")) - self.assertFalse(hasattr(self.request, "axes_http_accept")) - self.assertFalse(hasattr(self.request, "axes_updated")) - - @mock.patch("axes.middleware.get_failure_limit", return_value=5) - @mock.patch("axes.middleware.AxesProxyHandler.get_failures", return_value=5) - @mock.patch("django.conf.settings.INSTALLED_APPS", ["rest_framework"]) - @override_settings(AXES_LOCK_OUT_AT_FAILURE=True) - def test_lockout_response_with_drf_integration( - self, mock_get_failure_limit, mock_get_failures - ): - def get_response(request): - return HttpResponse() - - response = AxesMiddleware(get_response)(self.request) - self.assertTrue(hasattr(self.request, "axes_locked_out")) - self.assertTrue(self.request.axes_locked_out) - self.assertEqual(response.status_code, self.STATUS_LOCKOUT) - - @mock.patch("axes.middleware.get_failure_limit", return_value=5) - @mock.patch("axes.middleware.AxesProxyHandler.get_failures", return_value=3) - @mock.patch("django.conf.settings.INSTALLED_APPS", ["rest_framework"]) - @override_settings(AXES_LOCK_OUT_AT_FAILURE=True) - def test_success_response_with_drf_integration( - self, mock_get_failure_limit, mock_get_failures - ): - def get_response(request): - return HttpResponse() - - response = AxesMiddleware(get_response)(self.request) - self.assertTrue(hasattr(self.request, "axes_locked_out")) - self.assertFalse(self.request.axes_locked_out) - self.assertEqual(response.status_code, self.STATUS_SUCCESS) diff --git a/docs/6_integration.rst b/docs/6_integration.rst index 3233413..54ff8e6 100644 --- a/docs/6_integration.rst +++ b/docs/6_integration.rst @@ -100,45 +100,44 @@ Integration with Django REST Framework -------------------------------------- .. note:: - Modern versions of Django REST Framework after 3.7.0 work normally with Axes - out-of-the-box and require no customization in DRF. + DRF versions prior to 3.7.0 will not function properly. -Django REST Framework versions prior to 3.7.0 -require the request object to be passed for authentication -by a customized DRF authentication class:: +Django Axes requires REST Framework to be connected +via lockout signals for correct functionality. - from rest_framework.authentication import BasicAuthentication +You can use the following snippet in your project signals such as ``example/signals.py``:: - class AxesBasicAuthentication(BasicAuthentication): - """ - Extended basic authentication backend class that supplies the - request object into the authentication call for Axes compatibility. + from django.dispatch import receiver - NOTE: This patch is only needed for DRF versions < 3.7.0. - """ + from axes.signals import user_locked_out + from rest_framework.exceptions import PermissionDenied - def authenticate(self, request): - # NOTE: Request is added as an instance attribute in here - self._current_request = request - return super().authenticate(request) + @receiver(user_locked_out) + def raise_permission_denied(*args, **kwargs): + raise PermissionDenied("Too many failed login attempts") - def authenticate_credentials(self, userid, password, request=None): - credentials = { - get_user_model().USERNAME_FIELD: userid, - 'password': password - } +And then configure your application to load it in ``examples/apps.py``:: - # NOTE: Request is added as an argument to the authenticate call here - user = authenticate(request=request or self._current_request, **credentials) + from django import apps - if user is None: - raise exceptions.AuthenticationFailed(_('Invalid username/password.')) - if not user.is_active: - raise exceptions.AuthenticationFailed(_('User inactive or deleted.')) + class AppConfig(apps.AppConfig): + name = "example" - return (user, None) + def ready(self): + from example import signals # noqa + +Please check the Django signals documentation for more information: + +https://docs.djangoproject.com/en/3.1/topics/signals/ + +When a user login fails a signal is emitted and PermissionDenied +raises a HTTP 403 reply which interrupts the login process. + +This functionality was handled in the middleware for a time, +but that resulted in extra database requests being made for +each and every web request, and was migrated to signals. Integration with Django Simple Captcha