From 9c2ceb7eb7a025310e72ba0948025b5c928d0ae3 Mon Sep 17 00:00:00 2001 From: Antoine Dujardin Date: Tue, 22 Mar 2022 13:58:23 +0100 Subject: [PATCH] Add option to keep current behavior for cooloff reset --- axes/backends.py | 4 +++- axes/conf.py | 5 +++++ axes/handlers/cache.py | 5 ++++- axes/handlers/database.py | 5 ++++- docs/4_configuration.rst | 3 +++ tests/test_login.py | 35 ++++++++++++++++++++++++++++++----- 6 files changed, 49 insertions(+), 8 deletions(-) diff --git a/axes/backends.py b/axes/backends.py index 926eef0..c337f89 100644 --- a/axes/backends.py +++ b/axes/backends.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.contrib.auth.backends import ModelBackend from axes.exceptions import ( @@ -52,7 +53,8 @@ class AxesBackend(ModelBackend): response_context["error"] = error_msg # This flag can be used later to check if it was Axes that denied the login attempt. - request.axes_locked_out = True + if not settings.AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT: + request.axes_locked_out = True # Raise an error that stops the authentication flows at django.contrib.auth.authenticate. # This error stops bubbling up at the authenticate call which catches backend PermissionDenied errors. diff --git a/axes/conf.py b/axes/conf.py index 63d6ce8..ad7ccbe 100644 --- a/axes/conf.py +++ b/axes/conf.py @@ -136,3 +136,8 @@ settings.AXES_CLIENT_STR_CALLABLE = getattr(settings, "AXES_CLIENT_STR_CALLABLE" # set the HTTP response code given by too many requests settings.AXES_HTTP_RESPONSE_CODE = getattr(settings, "AXES_HTTP_RESPONSE_CODE", 403) + +# If True, a failed login attempt during lockout will reset the cool off period +settings.AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT = getattr( + settings, "AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT", True +) diff --git a/axes/handlers/cache.py b/axes/handlers/cache.py index 38dae3c..8bddae1 100644 --- a/axes/handlers/cache.py +++ b/axes/handlers/cache.py @@ -84,7 +84,10 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler): return # If axes denied access, don't record the failed attempt as that would reset the lockout time. - if request.axes_locked_out: + if ( + not settings.AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT + and request.axes_locked_out + ): request.axes_credentials = credentials user_locked_out.send( "axes", diff --git a/axes/handlers/database.py b/axes/handlers/database.py index e69e06d..176c898 100644 --- a/axes/handlers/database.py +++ b/axes/handlers/database.py @@ -140,7 +140,10 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler): ) # If axes denied access, don't record the failed attempt as that would reset the lockout time. - if request.axes_locked_out: + if ( + not settings.AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT + and request.axes_locked_out + ): request.axes_credentials = credentials user_locked_out.send( "axes", diff --git a/docs/4_configuration.rst b/docs/4_configuration.rst index f4abac8..8d2b552 100644 --- a/docs/4_configuration.rst +++ b/docs/4_configuration.rst @@ -125,6 +125,9 @@ The following ``settings.py`` options are available for customizing Axes behavio reached. For example: ``AXES_HTTP_RESPONSE_CODE = 429`` Default: ``403`` +* ``AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT``: If ``True``, a failed login attempt during lockout will + reset the cool off period. + Default: ``True`` The configuration option precedences for the access attempt monitoring are: diff --git a/tests/test_login.py b/tests/test_login.py index 9541825..07d5a45 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -13,7 +13,7 @@ from django.test import override_settings, TestCase from django.urls import reverse from axes.conf import settings -from axes.helpers import get_cache, make_cache_key_list, get_cool_off +from axes.helpers import get_cache, make_cache_key_list, get_cool_off, get_failure_limit from axes.models import AccessAttempt from tests.base import AxesTestCase @@ -632,20 +632,45 @@ class DatabaseLoginTestCase(AxesTestCase): response = self.client.get(reverse("admin:login"), REMOTE_ADDR=self.IP_1) self.assertContains(response, self.LOGIN_FORM_KEY, status_code=200, html=True) - @override_settings(AXES_COOLOFF_TIME=timedelta(seconds=1)) + @override_settings( + AXES_COOLOFF_TIME=timedelta(seconds=1), + AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT=False, + AXES_FAILURE_LIMIT=2, + ) def test_login_during_lockout_doesnt_reset_cool_off_time(self): # Lockout - self.lockout() + for _ in range(get_failure_limit(None, None)): + self.login(self.USER_1) # Attempt during lockout sleep_time = get_cool_off().total_seconds() / 2 sleep(sleep_time) - self.login() + self.login(self.USER_1) sleep(sleep_time) # New attempt after initial lockout period: should work response = self.login(is_valid_username=True, is_valid_password=True) - self.assertNotContains(response, self.LOCKED_MESSAGE, status_code=302) + self.assertNotContains(response, self.LOCKED_MESSAGE, status_code=self.ALLOWED) + + @override_settings( + AXES_COOLOFF_TIME=timedelta(seconds=1), + AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT=True, + AXES_FAILURE_LIMIT=2, + ) + def test_login_during_lockout_does_reset_cool_off_time(self): + # Lockout + for _ in range(get_failure_limit(None, None)): + self.login(self.USER_1) + + # Attempt during lockout + sleep_time = get_cool_off().total_seconds() / 2 + sleep(sleep_time) + self.login(self.USER_1) + sleep(sleep_time) + + # New attempt after initial lockout period: should not work + response = self.login(is_valid_username=True, is_valid_password=True) + self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED) # Test the same logic with cache handler