diff --git a/axes/conf.py b/axes/conf.py index edd4a41..c647689 100644 --- a/axes/conf.py +++ b/axes/conf.py @@ -41,6 +41,9 @@ class AxesAppConf(AppConf): # determine if given user should be always allowed to attempt authentication WHITELIST_CALLABLE = None + # return custom lockout response if configured + LOCKOUT_CALLABLE = None + # reset the number of failed attempts after one successful attempt RESET_ON_SUCCESS = False diff --git a/axes/helpers.py b/axes/helpers.py index ac39cd5..cb8ee3a 100644 --- a/axes/helpers.py +++ b/axes/helpers.py @@ -308,6 +308,15 @@ def get_lockout_message() -> str: def get_lockout_response(request, credentials: dict = None) -> HttpResponse: + if settings.AXES_LOCKOUT_CALLABLE: + if callable(settings.AXES_LOCKOUT_CALLABLE): + return settings.AXES_LOCKOUT_CALLABLE(request, credentials) + if isinstance(settings.AXES_LOCKOUT_CALLABLE, str): + return import_string(settings.AXES_LOCKOUT_CALLABLE)(request, credentials) + raise TypeError( + "settings.AXES_LOCKOUT_CALLABLE needs to be a string, callable, or None." + ) + status = 403 context = { "failure_limit": get_failure_limit(request, credentials), diff --git a/axes/tests/test_helpers.py b/axes/tests/test_helpers.py index 992812b..4cbbc0b 100644 --- a/axes/tests/test_helpers.py +++ b/axes/tests/test_helpers.py @@ -1,22 +1,18 @@ from datetime import timedelta from django.contrib.auth import get_user_model -from django.http import HttpRequest +from django.http import HttpRequest, HttpResponse from django.test import override_settings -from axes.helpers import get_cool_off, is_user_attempt_whitelisted +from axes.helpers import get_cool_off, get_lockout_response, is_user_attempt_whitelisted from axes.tests.base import AxesTestCase -def get_cool_off_str(): +def mock_get_cool_off_str(): return timedelta(seconds=30) -def is_whitelisted(request, credentials): - return True - - -class AxesHelpersTestCase(AxesTestCase): +class AxesCoolOffTestCase(AxesTestCase): @override_settings(AXES_COOLOFF_TIME=None) def test_get_cool_off_none(self): self.assertIsNone(get_cool_off()) @@ -29,12 +25,18 @@ class AxesHelpersTestCase(AxesTestCase): def test_get_cool_off_callable(self): self.assertEqual(get_cool_off(), timedelta(seconds=30)) - @override_settings(AXES_COOLOFF_TIME="axes.tests.test_helpers.get_cool_off_str") - def test_get_cool_off_str(self): + @override_settings( + AXES_COOLOFF_TIME="axes.tests.test_helpers.mock_get_cool_off_str" + ) + def test_get_cool_off_path(self): self.assertEqual(get_cool_off(), timedelta(seconds=30)) -class UserWhitelistTestCase(AxesTestCase): +def mock_is_whitelisted(request, credentials): + return True + + +class AxesWhitelistTestCase(AxesTestCase): def setUp(self): self.user_model = get_user_model() self.user = self.user_model.objects.create(username="jane.doe") @@ -44,11 +46,13 @@ class UserWhitelistTestCase(AxesTestCase): def test_is_whitelisted(self): self.assertFalse(is_user_attempt_whitelisted(self.request, self.credentials)) - @override_settings(AXES_WHITELIST_CALLABLE=is_whitelisted) - def test_is_whitelisted_override(self): + @override_settings(AXES_WHITELIST_CALLABLE=mock_is_whitelisted) + def test_is_whitelisted_override_callable(self): self.assertTrue(is_user_attempt_whitelisted(self.request, self.credentials)) - @override_settings(AXES_WHITELIST_CALLABLE="axes.tests.test_helpers.is_whitelisted") + @override_settings( + AXES_WHITELIST_CALLABLE="axes.tests.test_helpers.mock_is_whitelisted" + ) def test_is_whitelisted_override_path(self): self.assertTrue(is_user_attempt_whitelisted(self.request, self.credentials)) @@ -56,3 +60,34 @@ class UserWhitelistTestCase(AxesTestCase): def test_is_whitelisted_override_invalid(self): with self.assertRaises(TypeError): is_user_attempt_whitelisted(self.request, self.credentials) + + +def mock_get_lockout_response(request, credentials): + return HttpResponse(status=400) + + +class AxesLockoutTestCase(AxesTestCase): + def setUp(self): + self.request = HttpRequest() + self.credentials = dict() + + def test_get_lockout_response(self): + response = get_lockout_response(self.request, self.credentials) + self.assertEqual(403, response.status_code) + + @override_settings(AXES_LOCKOUT_CALLABLE=mock_get_lockout_response) + def test_get_lockout_response_override_callable(self): + response = get_lockout_response(self.request, self.credentials) + self.assertEqual(400, response.status_code) + + @override_settings( + AXES_LOCKOUT_CALLABLE="axes.tests.test_helpers.mock_get_lockout_response" + ) + def test_get_lockout_response_override_path(self): + response = get_lockout_response(self.request, self.credentials) + self.assertEqual(400, response.status_code) + + @override_settings(AXES_LOCKOUT_CALLABLE=42) + def test_get_lockout_response_override_invalid(self): + with self.assertRaises(TypeError): + get_lockout_response(self.request, self.credentials) diff --git a/docs/4_configuration.rst b/docs/4_configuration.rst index 6e83edd..1550181 100644 --- a/docs/4_configuration.rst +++ b/docs/4_configuration.rst @@ -80,7 +80,14 @@ The following ``settings.py`` options are available for customizing Axes behavio two arguments for whitelisting determination and returns True, if user should be whitelisted: ``def is_whilisted(request: HttpRequest, credentials: dict) -> bool: ...``. - This can be any callable similarly to ``AXES_USERNAME_CALLABLE`` + This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. + Default: ``None`` +* ``AXES_LOCKOUT_CALLABLE``: A callable or a string path to callable that takes + two arguments returns a response. For example: + ``def generate_lockout_response(request: HttpRequest, credentials: dict) -> HttpResponse: ...``. + This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. + If not callable is defined, then the default implementation in ``axes.helpers.get_lockout_response`` + is used for determining the correct lockout response that is sent to the requesting client. Default: ``None`` * ``AXES_PASSWORD_FORM_FIELD``: the name of the form or credentials field that contains your users password. Default: ``password``