diff --git a/axes/helpers.py b/axes/helpers.py index a7ccf60..7921681 100644 --- a/axes/helpers.py +++ b/axes/helpers.py @@ -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: diff --git a/docs/4_configuration.rst b/docs/4_configuration.rst index 36c4af5..a94704c 100644 --- a/docs/4_configuration.rst +++ b/docs/4_configuration.rst @@ -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 `_) + 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. diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 584cfc4..4030873 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -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)