Skip to content
5 changes: 5 additions & 0 deletions axes/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ def _get_username_field_default():
# 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
Expand Down
27 changes: 21 additions & 6 deletions axes/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
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:
Expand Down Expand Up @@ -39,6 +39,23 @@ def __init__(self, get_response: Callable) -> None:
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

Comment on lines +42 to +58
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldl it make sense to just write this as a simpler single if-then method? Why do we need to skip setting the header on callable and lockout URLs?

I think a better location for this function could be the axes.helpers module since it contains other similar implementations for getters and this can be just a generic function (although it is quite middleware-ish since it uses both response and request objects).

Since this method actually mutates the response object without copying it it should probably just set the header, no need to return a new object really:

Suggested change
@staticmethod
def _set_retry_after_header(
response: HttpResponse, request: HttpRequest
) -> HttpResponse:
if not settings.AXES_ENABLE_RETRY_AFTER_HEADER:
return response
if settings.AXES_LOCKOUT_CALLABLE or settings.AXES_LOCKOUT_URL:
return response
cool_off = get_cool_off(request)
if cool_off is not None:
response["Retry-After"] = str(int(cool_off.total_seconds()))
return response
def set_retry_after_header(
request: HttpRequest, response: HttpResponse
):
if settings.AXES_ENABLE_RETRY_AFTER_HEADER:
response["Retry-After"] = str(int(get_cool_off(request).total_seconds()))

Then it can just be simply invoked here or maybe even preferably in axes.helpers.get_lockout_response where it could live in one place instead of multiple:

Suggested change
@staticmethod
def _set_retry_after_header(
response: HttpResponse, request: HttpRequest
) -> HttpResponse:
if not settings.AXES_ENABLE_RETRY_AFTER_HEADER:
return response
if settings.AXES_LOCKOUT_CALLABLE or settings.AXES_LOCKOUT_URL:
return response
cool_off = get_cool_off(request)
if cool_off is not None:
response["Retry-After"] = str(int(cool_off.total_seconds()))
return response
set_retry_after_header(request, response)

This is easier to use and simple to test.

Copy link
Copy Markdown
Contributor Author

@rodrigobnogueira rodrigobnogueira Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done. It is simpler now. thanks

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me know if the implementation is better now.

def __call__(self, request: HttpRequest) -> HttpResponse:
# Exit out to async mode, if needed
if iscoroutinefunction(self):
Expand All @@ -48,7 +65,7 @@ def __call__(self, request: HttpRequest) -> HttpResponse:
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

Expand All @@ -59,9 +76,7 @@ async def __acall__(self, request: HttpRequest) -> HttpResponse:
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
8 changes: 8 additions & 0 deletions docs/4_configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 66 additions & 1 deletion tests/test_middleware.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -33,11 +39,70 @@ def get_response(request):
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()

Expand Down
Loading