This commit is contained in:
Rodrigo Nogueira 2026-03-21 02:54:57 -03:00 committed by GitHub
commit c93105e93d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 100 additions and 7 deletions

View file

@ -160,6 +160,11 @@ settings.AXES_CLIENT_STR_CALLABLE = getattr(settings, "AXES_CLIENT_STR_CALLABLE"
# set the HTTP response code given by too many requests
settings.AXES_HTTP_RESPONSE_CODE = getattr(settings, "AXES_HTTP_RESPONSE_CODE", 429)
# if True, set Retry-After header for lockout responses with cool off configured
settings.AXES_ENABLE_RETRY_AFTER_HEADER = getattr(
settings, "AXES_ENABLE_RETRY_AFTER_HEADER", False
)
# If True, a failed login attempt during lockout will reset the cool off period
settings.AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT = getattr(
settings, "AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT", True

View file

@ -4,7 +4,7 @@ from asgiref.sync import iscoroutinefunction, markcoroutinefunction, sync_to_asy
from django.conf import settings
from django.http import HttpRequest, HttpResponse
from axes.helpers import get_lockout_response
from axes.helpers import get_cool_off, get_lockout_response
class AxesMiddleware:
@ -39,6 +39,23 @@ class AxesMiddleware:
if iscoroutinefunction(self.get_response):
markcoroutinefunction(self)
@staticmethod
def set_retry_after_header(request: HttpRequest, response: HttpResponse) -> None:
if settings.AXES_ENABLE_RETRY_AFTER_HEADER:
cool_off = get_cool_off(request)
if cool_off is not None:
response["Retry-After"] = str(int(cool_off.total_seconds()))
def build_lockout_response(
self,
request: HttpRequest,
response: HttpResponse,
credentials,
) -> HttpResponse:
response = get_lockout_response(request, response, credentials) # type: ignore
self.set_retry_after_header(request, response)
return response
def __call__(self, request: HttpRequest) -> HttpResponse:
# Exit out to async mode, if needed
if iscoroutinefunction(self):
@ -48,7 +65,7 @@ class AxesMiddleware:
if settings.AXES_ENABLED:
if getattr(request, "axes_locked_out", None):
credentials = getattr(request, "axes_credentials", None)
response = get_lockout_response(request, response, credentials) # type: ignore
response = self.build_lockout_response(request, response, credentials)
return response
@ -59,9 +76,7 @@ class AxesMiddleware:
if getattr(request, "axes_locked_out", None):
credentials = getattr(request, "axes_credentials", None)
response = await sync_to_async(
get_lockout_response, thread_sensitive=True
)(
request, credentials
) # type: ignore
self.build_lockout_response, thread_sensitive=True
)(request, response, credentials)
return response

View file

@ -83,11 +83,19 @@ The following ``settings.py`` options are available for customizing Axes behavio
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| AXES_HTTP_RESPONSE_CODE | 429 | Sets the http response code returned when ``AXES_FAILURE_LIMIT`` is reached. For example: ``AXES_HTTP_RESPONSE_CODE = 403`` |
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| AXES_ENABLE_RETRY_AFTER_HEADER | False | If ``True``, ``AxesMiddleware`` sets the ``Retry-After`` HTTP header on lockout responses when ``AXES_COOLOFF_TIME`` is configured. Set to ``False`` to disable this header. |
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT | True | If ``True``, any failed login attempt during lockout resets the cool-off timer to ``now() + AXES_COOLOFF_TIME``. Repeated failed attempts keep extending the lockout period. |
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| AXES_LOCKOUT_PARAMETERS | ["ip_address"] | A list of parameters that Axes uses to lock out users. It can also be callable, which takes an http request or AccesAttempt object and credentials and returns a list of parameters. Each parameter can be a string (a single parameter) or a list of strings (a combined parameter). For example, if you configure ``AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]``, axes will block clients by ip and/or username and user agent combination. See :ref:`customizing-lockout-parameters` for more details. |
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
.. note::
``AXES_ENABLE_RETRY_AFTER_HEADER`` defaults to ``False``.
If enabled and ``AXES_COOLOFF_TIME`` is configured,
``AxesMiddleware`` adds a ``Retry-After`` HTTP header (`RFC 7231 <https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.3>`_)
with the cool-off duration in seconds for lockout responses.
**Common configurations**
.. code-block:: python

View file

@ -1,3 +1,5 @@
from datetime import timedelta
from django.conf import settings
from django.http import HttpResponse, HttpRequest
from django.test import override_settings
@ -10,6 +12,10 @@ def get_username(request, credentials: dict) -> str:
return credentials.get(settings.AXES_USERNAME_FORM_FIELD)
def get_custom_lockout_response(request, original_response, credentials):
return HttpResponse(status=429)
class MiddlewareTestCase(AxesTestCase):
STATUS_SUCCESS = 200
STATUS_LOCKOUT = 429
@ -33,11 +39,70 @@ class MiddlewareTestCase(AxesTestCase):
response = AxesMiddleware(get_response)(self.request)
self.assertEqual(response.status_code, self.STATUS_LOCKOUT)
@override_settings(
AXES_COOLOFF_TIME=timedelta(seconds=120),
AXES_ENABLE_RETRY_AFTER_HEADER=True,
)
def test_lockout_response_sets_retry_after_header(self):
def get_response(request):
request.axes_locked_out = True
return HttpResponse()
response = AxesMiddleware(get_response)(self.request)
self.assertEqual(response["Retry-After"], "120")
@override_settings(AXES_COOLOFF_TIME=None)
def test_lockout_response_without_cooloff_does_not_set_retry_after_header(self):
def get_response(request):
request.axes_locked_out = True
return HttpResponse()
response = AxesMiddleware(get_response)(self.request)
self.assertFalse(response.has_header("Retry-After"))
@override_settings(
AXES_COOLOFF_TIME=timedelta(seconds=120),
AXES_ENABLE_RETRY_AFTER_HEADER=False,
)
def test_lockout_response_respects_retry_after_toggle(self):
def get_response(request):
request.axes_locked_out = True
return HttpResponse()
response = AxesMiddleware(get_response)(self.request)
self.assertFalse(response.has_header("Retry-After"))
@override_settings(
AXES_COOLOFF_TIME=timedelta(seconds=120),
AXES_ENABLE_RETRY_AFTER_HEADER=True,
AXES_LOCKOUT_URL="https://example.com",
)
def test_lockout_redirect_response_sets_retry_after_header(self):
def get_response(request):
request.axes_locked_out = True
return HttpResponse()
response = AxesMiddleware(get_response)(self.request)
self.assertEqual(response["Retry-After"], "120")
@override_settings(
AXES_COOLOFF_TIME=timedelta(seconds=120),
AXES_ENABLE_RETRY_AFTER_HEADER=True,
AXES_LOCKOUT_CALLABLE="tests.test_middleware.get_custom_lockout_response",
)
def test_lockout_callable_response_sets_retry_after_header(self):
def get_response(request):
request.axes_locked_out = True
return HttpResponse()
response = AxesMiddleware(get_response)(self.request)
self.assertEqual(response["Retry-After"], "120")
@override_settings(AXES_USERNAME_CALLABLE="tests.test_middleware.get_username")
def test_lockout_response_with_axes_callable_username(self):
def get_response(request):
request.axes_locked_out = True
request.axes_credentials = {settings.AXES_USERNAME_FORM_FIELD: 'username'}
request.axes_credentials = {settings.AXES_USERNAME_FORM_FIELD: "username"}
return HttpResponse()