This commit is contained in:
Rodrigo Nogueira 2026-02-22 04:59:02 +00:00 committed by GitHub
commit e4842012fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 50 additions and 2 deletions

View file

@ -461,6 +461,12 @@ def get_lockout_message() -> str:
return settings.AXES_PERMALOCK_MESSAGE
def _set_retry_after_header(response: HttpResponse, request: HttpRequest) -> None:
cool_off = get_cool_off(request)
if cool_off is not None:
response["Retry-After"] = str(int(cool_off.total_seconds()))
def get_lockout_response(
request: HttpRequest,
original_response: Optional[HttpResponse] = None,
@ -513,10 +519,15 @@ def get_lockout_response(
json_response["Access-Control-Allow-Headers"] = (
"Origin, Content-Type, Accept, Authorization, x-requested-with"
)
_set_retry_after_header(json_response, request)
return json_response
if settings.AXES_LOCKOUT_TEMPLATE:
return render(request, settings.AXES_LOCKOUT_TEMPLATE, context, status=status)
response = render(
request, settings.AXES_LOCKOUT_TEMPLATE, context, status=status
)
_set_retry_after_header(response, request)
return response
if settings.AXES_LOCKOUT_URL:
lockout_url = settings.AXES_LOCKOUT_URL
@ -524,7 +535,9 @@ def get_lockout_response(
url = f"{lockout_url}?{query_string}"
return redirect(url)
return HttpResponse(get_lockout_message(), status=status)
response = HttpResponse(get_lockout_message(), status=status)
_set_retry_after_header(response, request)
return response
def is_ip_address_in_whitelist(ip_address: str) -> bool:

View file

@ -86,6 +86,13 @@ The following ``settings.py`` options are available for customizing Axes behavio
| 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::
When ``AXES_COOLOFF_TIME`` is configured, lockout responses automatically include 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. This applies to JSON, template-rendered, and
plain-text lockout responses, but not to redirects (``AXES_LOCKOUT_URL``) or custom
callables (``AXES_LOCKOUT_CALLABLE``).
The configuration option precedences for the access attempt monitoring are:
1. Default: only use IP address.

View file

@ -946,6 +946,34 @@ class LockoutResponseTestCase(AxesTestCase):
response = get_lockout_response(request=self.request)
self.assertEqual(type(response), HttpResponse)
@override_settings(AXES_COOLOFF_TIME=2)
def test_get_lockout_response_retry_after_header(self):
response = get_lockout_response(request=self.request)
self.assertEqual(response["Retry-After"], "7200")
@override_settings(AXES_COOLOFF_TIME=None)
def test_get_lockout_response_retry_after_no_cooloff(self):
response = get_lockout_response(request=self.request)
self.assertFalse(response.has_header("Retry-After"))
@override_settings(AXES_COOLOFF_TIME=2)
def test_get_lockout_response_retry_after_json(self):
self.request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest"
response = get_lockout_response(request=self.request)
self.assertEqual(response["Retry-After"], "7200")
@override_settings(AXES_COOLOFF_TIME=2, AXES_LOCKOUT_TEMPLATE="example.html")
@patch("axes.helpers.render")
def test_get_lockout_response_retry_after_template(self, mock_render):
mock_render.return_value = HttpResponse(status=429)
response = get_lockout_response(request=self.request)
self.assertEqual(response["Retry-After"], "7200")
@override_settings(AXES_COOLOFF_TIME=2, AXES_LOCKOUT_URL="https://example.com")
def test_get_lockout_response_retry_after_redirect_absent(self):
response = get_lockout_response(request=self.request)
self.assertFalse(response.has_header("Retry-After"))
def mock_get_cool_off_str(req):
return timedelta(seconds=30)