Skip to content

Commit 17730a5

Browse files
rodrigobnogueiraaleksihakli
authored andcommitted
feat: add opt-in Retry-After header on lockout responses
Adds AXES_ENABLE_RETRY_AFTER_HEADER (default False). When enabled and a cool-off is configured, lockout responses carry a Retry-After header with the cool-off duration in seconds (RFC 7231). Per review, the logic lives in axes.helpers as a small set_retry_after_header() helper invoked from get_lockout_response, so the header is set in one place for every built-in lockout response (JSON, template, redirect, default) and the middleware needs no changes. A custom AXES_LOCKOUT_CALLABLE owns its response, so it is left untouched. No cool-off (permanent lockout) means no header. Closes the review feedback on #1401.
1 parent c4178a8 commit 17730a5

4 files changed

Lines changed: 85 additions & 14 deletions

File tree

axes/conf.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,11 @@ def _get_username_field_default():
173173
# set the HTTP response code given by too many requests
174174
settings.AXES_HTTP_RESPONSE_CODE = getattr(settings, "AXES_HTTP_RESPONSE_CODE", 429)
175175

176+
# If True, add a Retry-After header to lockout responses when a cool off is set
177+
settings.AXES_ENABLE_RETRY_AFTER_HEADER = getattr(
178+
settings, "AXES_ENABLE_RETRY_AFTER_HEADER", False
179+
)
180+
176181
# If True, a failed login attempt during lockout will reset the cool off period
177182
settings.AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT = getattr(
178183
settings, "AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT", True

axes/helpers.py

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,20 @@ def get_lockout_message() -> str:
498498
return settings.AXES_PERMALOCK_MESSAGE
499499

500500

501+
def set_retry_after_header(request: HttpRequest, response: HttpResponse) -> None:
502+
"""Set a ``Retry-After`` header on a lockout ``response``.
503+
504+
No-op unless ``AXES_ENABLE_RETRY_AFTER_HEADER`` is enabled and a cool-off
505+
period is configured — a permanent lockout has no meaningful retry time.
506+
The response is mutated in place; nothing is returned.
507+
"""
508+
if not settings.AXES_ENABLE_RETRY_AFTER_HEADER:
509+
return
510+
cool_off = get_cool_off(request)
511+
if cool_off is not None:
512+
response["Retry-After"] = str(int(cool_off.total_seconds()))
513+
514+
501515
def get_lockout_response(
502516
request: HttpRequest,
503517
original_response: Optional[HttpResponse] = None,
@@ -543,27 +557,28 @@ def get_lockout_response(
543557
}
544558
)
545559

560+
response: HttpResponse
546561
if request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest":
547-
json_response = JsonResponse(context, status=status)
548-
json_response["Access-Control-Allow-Origin"] = (
549-
settings.AXES_ALLOWED_CORS_ORIGINS
550-
)
551-
json_response["Access-Control-Allow-Methods"] = "POST, OPTIONS"
552-
json_response["Access-Control-Allow-Headers"] = (
562+
response = JsonResponse(context, status=status)
563+
response["Access-Control-Allow-Origin"] = settings.AXES_ALLOWED_CORS_ORIGINS
564+
response["Access-Control-Allow-Methods"] = "POST, OPTIONS"
565+
response["Access-Control-Allow-Headers"] = (
553566
"Origin, Content-Type, Accept, Authorization, x-requested-with"
554567
)
555-
return json_response
556-
557-
if settings.AXES_LOCKOUT_TEMPLATE:
558-
return render(request, settings.AXES_LOCKOUT_TEMPLATE, context, status=status)
559-
560-
if settings.AXES_LOCKOUT_URL:
568+
elif settings.AXES_LOCKOUT_TEMPLATE:
569+
response = render(
570+
request, settings.AXES_LOCKOUT_TEMPLATE, context, status=status
571+
)
572+
elif settings.AXES_LOCKOUT_URL:
561573
lockout_url = settings.AXES_LOCKOUT_URL
562574
query_string = urlencode({"username": context["username"]})
563575
url = f"{lockout_url}?{query_string}"
564-
return redirect(url)
576+
response = redirect(url)
577+
else:
578+
response = HttpResponse(get_lockout_message(), status=status)
565579

566-
return HttpResponse(get_lockout_message(), status=status)
580+
set_retry_after_header(request, response)
581+
return response
567582

568583

569584
def is_ip_address_in_whitelist(ip_address: str) -> bool:

docs/4_configuration.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,19 @@ The following ``settings.py`` options are available for customizing Axes behavio
8585
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
8686
| AXES_HTTP_RESPONSE_CODE | 429 | Sets the http response code returned when ``AXES_FAILURE_LIMIT`` is reached. For example: ``AXES_HTTP_RESPONSE_CODE = 403`` |
8787
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
88+
| AXES_ENABLE_RETRY_AFTER_HEADER | False | If ``True``, Axes adds a ``Retry-After`` HTTP header to lockout responses when ``AXES_COOLOFF_TIME`` is configured. Set to ``False`` to disable it. |
89+
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
8890
| 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. |
8991
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
9092
| 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. |
9193
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
9294

95+
.. note::
96+
``AXES_ENABLE_RETRY_AFTER_HEADER`` defaults to ``False``. When enabled and
97+
``AXES_COOLOFF_TIME`` is configured, Axes adds a ``Retry-After`` HTTP header
98+
(`RFC 7231 <https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.3>`_) to
99+
lockout responses with the cool-off duration in seconds.
100+
93101
**Common configurations**
94102

95103
.. code-block:: python

tests/test_helpers.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
is_ip_address_in_blacklist,
2727
is_ip_address_in_whitelist,
2828
is_user_attempt_whitelisted,
29+
set_retry_after_header,
2930
toggleable,
3031
)
3132
from axes.models import AccessAttempt
@@ -948,6 +949,48 @@ def test_get_lockout_response_lockout_response(self):
948949
response = get_lockout_response(request=self.request)
949950
self.assertEqual(type(response), HttpResponse)
950951

952+
@override_settings(
953+
AXES_COOLOFF_TIME=timedelta(seconds=120),
954+
AXES_ENABLE_RETRY_AFTER_HEADER=True,
955+
)
956+
def test_lockout_response_sets_retry_after_header(self):
957+
response = get_lockout_response(request=self.request)
958+
self.assertEqual(response["Retry-After"], "120")
959+
960+
@override_settings(
961+
AXES_COOLOFF_TIME=timedelta(seconds=120),
962+
AXES_ENABLE_RETRY_AFTER_HEADER=False,
963+
)
964+
def test_lockout_response_omits_retry_after_header_when_disabled(self):
965+
response = get_lockout_response(request=self.request)
966+
self.assertFalse(response.has_header("Retry-After"))
967+
968+
@override_settings(
969+
AXES_COOLOFF_TIME=None,
970+
AXES_ENABLE_RETRY_AFTER_HEADER=True,
971+
)
972+
def test_lockout_response_omits_retry_after_header_without_cool_off(self):
973+
response = get_lockout_response(request=self.request)
974+
self.assertFalse(response.has_header("Retry-After"))
975+
976+
@override_settings(
977+
AXES_COOLOFF_TIME=timedelta(seconds=120),
978+
AXES_ENABLE_RETRY_AFTER_HEADER=True,
979+
AXES_LOCKOUT_URL="https://example.com",
980+
)
981+
def test_retry_after_header_set_on_redirect_lockout_response(self):
982+
response = get_lockout_response(request=self.request)
983+
self.assertEqual(response["Retry-After"], "120")
984+
985+
@override_settings(
986+
AXES_COOLOFF_TIME=timedelta(seconds=90),
987+
AXES_ENABLE_RETRY_AFTER_HEADER=True,
988+
)
989+
def test_set_retry_after_header_writes_cool_off_seconds(self):
990+
response = HttpResponse()
991+
set_retry_after_header(self.request, response)
992+
self.assertEqual(response["Retry-After"], "90")
993+
951994

952995
def mock_get_cool_off_str(req):
953996
return timedelta(seconds=30)

0 commit comments

Comments
 (0)