Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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:
Copy link
Member

Choose a reason for hiding this comment

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

Hey @rodrigobnogueira, would the Axes middleware be a good place for this?

Copy link
Author

Choose a reason for hiding this comment

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

Thanks @aleksihakli ,

moved Retry-After handling into AxesMiddleware so lockout response header logic is centralized there

cool_off = get_cool_off(request)
if cool_off is not None:
response["Retry-After"] = str(int(cool_off.total_seconds()))
Copy link
Member

Choose a reason for hiding this comment

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

Could settings this header be toggled with a flag?

Copy link
Author

Choose a reason for hiding this comment

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

Added AXES_ENABLE_RETRY_AFTER_HEADER (default False to not change current user's expectations) so projects can toggle this behavior.



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