Move DRF integration into signals

Add documentation on how to enable the integration
and remove the logic from global middleware.

Fixes #673
This commit is contained in:
Aleksi Häkli 2020-12-16 18:00:00 +02:00
parent 9acf904568
commit 59bcbd8816
5 changed files with 37 additions and 121 deletions

View file

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

View file

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

View file

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

View file

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

View file

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