Skip to content
19 changes: 17 additions & 2 deletions axes/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ def get_cool_off_iso8601(delta: timedelta) -> str:
return f"P{days_str}T{time_str}"
return f"P{days_str}"


def get_attempt_expiration(request: Optional[HttpRequest] = None) -> datetime:
"""
Get threshold for fetching access attempts from the database.
Expand All @@ -116,6 +117,7 @@ def get_attempt_expiration(request: Optional[HttpRequest] = None) -> datetime:
return datetime.now() + cool_off
return attempt_time + cool_off


def get_credentials(username: Optional[str] = None, **kwargs) -> dict:
"""
Calculate credentials for Axes to use internally from given username and kwargs.
Expand Down Expand Up @@ -459,6 +461,12 @@ def get_lockout_message() -> str:
return settings.AXES_PERMALOCK_MESSAGE


def _set_retry_after_header(response: HttpResponse, request: HttpRequest) -> None:
Comment thread
rodrigobnogueira marked this conversation as resolved.
Outdated
cool_off = get_cool_off(request)
if cool_off is not None:
response["Retry-After"] = str(int(cool_off.total_seconds()))
Comment thread
rodrigobnogueira marked this conversation as resolved.
Outdated


def get_lockout_response(
request: HttpRequest,
original_response: Optional[HttpResponse] = None,
Expand Down Expand Up @@ -511,18 +519,25 @@ 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
query_string = urlencode({"username": context["username"]})
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:
Expand Down
7 changes: 7 additions & 0 deletions docs/4_configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions tests/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,34 @@ def test_get_lockout_response_lockout_response(self):
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)
Expand Down
Loading