Repository: jazzband/django-axes (python)

Issue:
Failed logins and attempt time updated even when lockedout
I'm not sure if this is intended or not, didn't find any information in the docs related to this.

The problem is that is an user is lockedout, the failed logins and the attempt time gets updated. This way `AXES_COOLOFF_TIME` is not working as expected, or at least as I would expect.

Example with `AXES_COOLOFF_TIME=1` (hour):
- Current behaviour
1. User fails to login 3 times, the last attempt at 10:05.
2. User gets lockedout until 11:05.
3. User tries to login at 10:50, and failed logins updates to 4, and attempt time updates to 10:50, so the user will be lockedout until 11:50. Also this is true even if the 4th attempt was with the right credentials.

- Expected behaviour
1. User fails to login 3 times, the last attempt at 10:05.
2. User gets lockedout until 11:05.
3. User tries to login at 10:50, the user will not login to the site even with the right credentials, but the attempt will NOT be taken into account.

I think this should be the typical behaviour, because the user probably won't know the exact time of the last failed login, maybe he thinks that the last attempt was at 10:00, and he tries to access at 11:01, but then he will see that now he is going to be lockedout 1 more hour, even if he tried with the right credentials. The current behaviour leads us to a scenario where an user could be lockedout easily for 2 or 3 hours, if he tries to access before the correct time, instead of the intended `AXES_COOLOFF_TIME` of 1 hour.

I hope I explained correctly. Thanks for your time!

Pull request description:
Fix #837 - Don't reset cooloff time after login attempt during lockout
Fixes https://github.com/jazzband/django-axes/issues/837

Adds the `AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT` option to not reset cooloff time in case of a login attempt during lockout. It's set to `True` by default, so the default behavior won't change.

Patch:
diff --git a/axes/admin.py b/axes/admin.py
index ee36b31..93f3301 100644
--- a/axes/admin.py
+++ b/axes/admin.py
@@ -2,7 +2,7 @@ from django.contrib import admin
 from django.utils.translation import gettext_lazy as _
 
 from axes.conf import settings
-from axes.models import AccessAttempt, AccessLog
+from axes.models import AccessAttempt, AccessLog, AccessFailureLog
 
 
 class AccessAttemptAdmin(admin.ModelAdmin):
@@ -78,6 +78,42 @@ class AccessLogAdmin(admin.ModelAdmin):
         return False
 
 
+class AccessFailureLogAdmin(admin.ModelAdmin):
+    list_display = (
+        "attempt_time",
+        "ip_address",
+        "username",
+        "user_agent",
+        "path_info",
+        "locked_out",
+    )
+
+    list_filter = ["attempt_time", "locked_out", "path_info"]
+
+    search_fields = ["ip_address", "user_agent", "username", "path_info"]
+
+    date_hierarchy = "attempt_time"
+
+    fieldsets = (
+        (None, {"fields": ("path_info",)}),
+        (_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
+    )
+
+    readonly_fields = [
+        "user_agent",
+        "ip_address",
+        "username",
+        "http_accept",
+        "path_info",
+        "attempt_time",
+        "locked_out",
+    ]
+
+    def has_add_permission(self, request):
+        return False
+
+
 if settings.AXES_ENABLE_ADMIN:
     admin.site.register(AccessAttempt, AccessAttemptAdmin)
     admin.site.register(AccessLog, AccessLogAdmin)
+    admin.site.register(AccessFailureLog, AccessFailureLogAdmin)
diff --git a/axes/backends.py b/axes/backends.py
index 5616979..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 (
@@ -51,6 +52,10 @@ class AxesBackend(ModelBackend):
         response_context = kwargs.get("response_context", {})
         response_context["error"] = error_msg
 
+        # This flag can be used later to check if it was Axes that denied the login attempt.
+        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.
         # After this error is caught by authenticate it emits a signal indicating user login failed,
diff --git a/axes/conf.py b/axes/conf.py
index 67dc2d0..ad7ccbe 100644
--- a/axes/conf.py
+++ b/axes/conf.py
@@ -57,6 +57,10 @@ settings.AXES_RESET_ON_SUCCESS = getattr(settings, "AXES_RESET_ON_SUCCESS", Fals
 
 settings.AXES_DISABLE_ACCESS_LOG = getattr(settings, "AXES_DISABLE_ACCESS_LOG", False)
 
+settings.AXES_ENABLE_ACCESS_FAILURE_LOG = getattr(settings, "AXES_ENABLE_ACCESS_FAILURE_LOG", False)
+
+settings.AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT = getattr(settings, "AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT", 1000)
+
 settings.AXES_HANDLER = getattr(
     settings, "AXES_HANDLER", "axes.handlers.database.AxesDatabaseHandler"
 )
@@ -132,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/base.py b/axes/handlers/base.py
index fe3408f..59b715d 100644
--- a/axes/handlers/base.py
+++ b/axes/handlers/base.py
@@ -174,6 +174,29 @@ class AxesBaseHandler:  # pylint: disable=unused-argument
         """
         return 0
 
+    def reset_failure_logs(self, *, age_days: int = None) -> int:
+        """
+        Resets access failure logs that are older than given number of days.
+
+        This method makes more sense for the DB backend, but as it is used by the ProxyHandler
+        (via inherent), it needs to be defined here so we get compliant with all proxy methods.
+
+        Please overwrite it on each specialized handler as needed.
+        """
+        return 0
+
+
+    def remove_out_of_limit_failure_logs(self, *, username: str, limit: int = None) -> int:
+        """Remove access failure logs that are over
+        AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT for user username.
+
+        This method makes more sense for the DB backend, but as it is used by the ProxyHandler
+        (via inherent), it needs to be defined here so we get compliant with all proxy methods.
+
+        Please overwrite it on each specialized handler as needed.
+
+        """
+        return 0
 
 class AxesHandler(AbstractAxesHandler, AxesBaseHandler):
     """
diff --git a/axes/handlers/cache.py b/axes/handlers/cache.py
index ea0671f..8bddae1 100644
--- a/axes/handlers/cache.py
+++ b/axes/handlers/cache.py
@@ -24,7 +24,6 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
 
     def __init__(self):
         self.cache = get_cache()
-        self.cache_timeout = get_cache_timeout()
 
     def reset_attempts(
         self,
@@ -84,6 +83,20 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
             )
             return
 
+        # If axes denied access, don't record the failed attempt as that would reset the lockout time.
+        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",
+                request=request,
+                username=username,
+                ip_address=request.axes_ip_address,
+            )
+            return
+
         client_str = get_client_str(
             username,
             request.axes_ip_address,
@@ -115,7 +128,7 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
         cache_keys = get_client_cache_key(request, credentials)
         for cache_key in cache_keys:
             failures = self.cache.get(cache_key, default=0)
-            self.cache.set(cache_key, failures + 1, self.cache_timeout)
+            self.cache.set(cache_key, failures + 1, get_cache_timeout())
 
         if (
             settings.AXES_LOCK_OUT_AT_FAILURE
diff --git a/axes/handlers/database.py b/axes/handlers/database.py
index fcb9711..176c898 100644
--- a/axes/handlers/database.py
+++ b/axes/handlers/database.py
@@ -19,7 +19,7 @@ from axes.helpers import (
     get_failure_limit,
     get_query_str,
 )
-from axes.models import AccessLog, AccessAttempt
+from axes.models import AccessLog, AccessAttempt, AccessFailureLog
 from axes.signals import user_locked_out
 
 log = getLogger(__name__)
@@ -70,6 +70,35 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
 
         return count
 
+    def reset_failure_logs(self, *, age_days: int = None) -> int:
+        if age_days is None:
+            count, _ = AccessFailureLog.objects.all().delete()
+            log.info("AXES: Reset all %d access failure logs from database.", count)
+        else:
+            limit = timezone.now() - timezone.timedelta(days=age_days)
+            count, _ = AccessFailureLog.objects.filter(attempt_time__lte=limit).delete()
+            log.info(
+                "AXES: Reset %d access failure logs older than %d days from database.",
+                count,
+                age_days,
+            )
+
+        return count
+
+    def remove_out_of_limit_failure_logs(
+            self,
+            *,
+            username: str,
+            limit: int = settings.AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT) -> int:
+        count = 0
+        failures = AccessFailureLog.objects.filter(username=username)
+        out_of_limit_failures_logs = failures.count() - limit
+        if out_of_limit_failures_logs > 0:
+            for failure in failures[:out_of_limit_failures_logs]:
+                failure.delete()
+                count += 1
+        return count
+
     def get_failures(self, request, credentials: dict = None) -> int:
         attempts_list = get_user_attempts(request, credentials)
         attempt_count = max(
@@ -84,8 +113,10 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
         return attempt_count
 
     def user_login_failed(self, sender, credentials: dict, request=None, **kwargs):
-        """
-        When user login fails, save AccessAttempt record in database, mark request with lockout attribute and emit lockout signal.
+        """When user login fails, save AccessFailureLog record in database,
+        save AccessAttempt record in database, mark request with
+        lockout attribute and emit lockout signal.
+
         """
 
         log.info("AXES: User login failed, running database handler for failure.")
@@ -108,6 +139,20 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
             request,
         )
 
+        # If axes denied access, don't record the failed attempt as that would reset the lockout time.
+        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",
+                request=request,
+                username=username,
+                ip_address=request.axes_ip_address,
+            )
+            return
+
         # This replaces null byte chars that crash saving failures.
         get_data = get_query_str(request.GET).replace("\0", "0x00")
         post_data = get_query_str(request.POST).replace("\0", "0x00")
@@ -192,6 +237,21 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
                 ip_address=request.axes_ip_address,
             )
 
+        # 5. database entry: Log for ever the attempt in the AccessFailureLog
+        if settings.AXES_ENABLE_ACCESS_FAILURE_LOG:
+            with transaction.atomic():
+                AccessFailureLog.objects.create(
+                    username=username,
+                    ip_address=request.axes_ip_address,
+                    user_agent=request.axes_user_agent,
+                    http_accept=request.axes_http_accept,
+                    path_info=request.axes_path_info,
+                    attempt_time=request.axes_attempt_time,
+                    locked_out=request.axes_locked_out,
+                )
+                self.remove_out_of_limit_failure_logs(username=username)
+
+
     def user_logged_in(self, sender, request, user, **kwargs):
         """
         When user logs in, update the AccessLog related to the user.
diff --git a/axes/handlers/proxy.py b/axes/handlers/proxy.py
index a539db3..8c0a3f3 100644
--- a/axes/handlers/proxy.py
+++ b/axes/handlers/proxy.py
@@ -58,6 +58,14 @@ class AxesProxyHandler(AbstractAxesHandler, AxesBaseHandler):
     def reset_logs(cls, *, age_days: int = None) -> int:
         return cls.get_implementation().reset_logs(age_days=age_days)
 
+    @classmethod
+    def reset_failure_logs(cls, *, age_days: int = None) -> int:
+        return cls.get_implementation().reset_failure_logs(age_days=age_days)
+
+    @classmethod
+    def remove_out_of_limit_failure_logs(cls, *, username: str, limit: int = None) -> int:
+        return cls.get_implementation().remove_out_of_limit_failure_logs(username=username)
+
     @staticmethod
     def update_request(request):
         """
@@ -70,7 +78,8 @@ class AxesProxyHandler(AbstractAxesHandler, AxesBaseHandler):
             )
             return
         if not hasattr(request, "axes_updated"):
-            request.axes_locked_out = False
+            if not hasattr(request, "axes_locked_out"):
+                request.axes_locked_out = False
             request.axes_attempt_time = now()
             request.axes_ip_address = get_client_ip_address(request)
             request.axes_user_agent = get_client_user_agent(request)
diff --git a/axes/management/commands/axes_reset_failure_logs.py b/axes/management/commands/axes_reset_failure_logs.py
new file mode 100644
index 0000000..b109d2f
--- /dev/null
+++ b/axes/management/commands/axes_reset_failure_logs.py
@@ -0,0 +1,22 @@
+from django.core.management.base import BaseCommand
+
+from axes.handlers.proxy import AxesProxyHandler
+
+
+class Command(BaseCommand):
+    help = "Reset access failure log records older than given days."
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            "--age",
+            type=int,
+            default=30,
+            help="Maximum age for records to keep in days",
+        )
+
+    def handle(self, *args, **options):
+        count = AxesProxyHandler.reset_failure_logs(age_days=options["age"])
+        if count:
+            self.stdout.write(f"{count} logs removed.")
+        else:
+            self.stdout.write("No logs found.")
diff --git a/axes/migrations/0008_accessfailurelog.py b/axes/migrations/0008_accessfailurelog.py
new file mode 100644
index 0000000..c7956f4
--- /dev/null
+++ b/axes/migrations/0008_accessfailurelog.py
@@ -0,0 +1,30 @@
+# Generated by Django 3.2.12 on 2022-03-15 03:00
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('axes', '0007_alter_accessattempt_unique_together'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='AccessFailureLog',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('user_agent', models.CharField(db_index=True, max_length=255, verbose_name='User Agent')),
+                ('ip_address', models.GenericIPAddressField(db_index=True, null=True, verbose_name='IP Address')),
+                ('username', models.CharField(db_index=True, max_length=255, null=True, verbose_name='Username')),
+                ('http_accept', models.CharField(max_length=1025, verbose_name='HTTP Accept')),
+                ('path_info', models.CharField(max_length=255, verbose_name='Path')),
+                ('attempt_time', models.DateTimeField(auto_now_add=True, verbose_name='Attempt Time')),
+                ('locked_out', models.BooleanField(blank=True, default=False, verbose_name='Access lock out')),
+            ],
+            options={
+                'verbose_name': 'access failure',
+                'verbose_name_plural': 'access failures',
+            },
+        ),
+    ]
diff --git a/axes/models.py b/axes/models.py
index 14f13a2..58e5dbd 100644
--- a/axes/models.py
+++ b/axes/models.py
@@ -21,6 +21,18 @@ class AccessBase(models.Model):
         ordering = ["-attempt_time"]
 
 
+class AccessFailureLog(AccessBase):
+    locked_out = models.BooleanField(_("Access lock out"), null=False, blank=True, default=False)
+
+    def __str__(self):
+        locked_out_str = ' locked out' if self.locked_out else ''
+        return f"Failed access: user {self.username}{locked_out_str} on {self.attempt_time} from {self.ip_address}"
+
+    class Meta:
+        verbose_name = _("access failure")
+        verbose_name_plural = _("access failures")
+
+
 class AccessAttempt(AccessBase):
     get_data = models.TextField(_("GET Data"))
 
diff --git a/docs/4_configuration.rst b/docs/4_configuration.rst
index 7e2bb89..8d2b552 100644
--- a/docs/4_configuration.rst
+++ b/docs/4_configuration.rst
@@ -110,6 +110,13 @@ The following ``settings.py`` options are available for customizing Axes behavio
 * ``AXES_DISABLE_ACCESS_LOG``: If ``True``, disable writing login and logout access logs to database,
   so the admin interface will not have user login trail for successful user authentication.
   Default: ``False``
+* ``AXES_ENABLE_ACCESS_FAILURE_LOG``: If ``True``, enable writing
+  login failure logs to database, so you will have every user login
+  trail for unsuccessful user authentication.  Default: ``False``
+* ``AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT``: Sets the number of
+  failures to trail for each user. When the access failure log reach
+  this number of records, an automatic removal is ran.  Default:
+  ``1000``
 * ``AXES_RESET_ON_SUCCESS``: If ``True``, a successful login will reset the number of failed logins.
   Default: ``False``
 * ``AXES_ALLOWED_CORS_ORIGINS``: Configures lockout response CORS headers for XHR requests.
@@ -118,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/requirements-qa.txt b/requirements-qa.txt
index 450cdd9..a920369 100644
--- a/requirements-qa.txt
+++ b/requirements-qa.txt
@@ -1,4 +1,4 @@
-black==22.1.0
-mypy==0.941
+black==22.3.0
+mypy==0.942
 prospector==1.7.7
 types-pkg_resources  # Type stub


Test patch:
diff --git a/requirements-test.txt b/requirements-test.txt
index 1ec2daf..6657e36 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -1,6 +1,6 @@
 -e .
 coverage==6.3.2
-pytest==7.1.0
+pytest==7.1.1
 pytest-cov==3.0.0
 pytest-django==4.5.2
 pytest-subtests==0.7.0
diff --git a/tests/base.py b/tests/base.py
index 52f32b5..be5cd56 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -19,7 +19,7 @@ from axes.helpers import (
     get_credentials,
     get_failure_limit,
 )
-from axes.models import AccessAttempt, AccessLog
+from axes.models import AccessAttempt, AccessLog, AccessFailureLog
 from axes.utils import reset
 
 
@@ -79,6 +79,7 @@ class AxesTestCase(TestCase):
         self.request.axes_path_info = get_client_path_info(self.request)
         self.request.axes_http_accept = get_client_http_accept(self.request)
         self.request.axes_failures_since_start = None
+        self.request.axes_locked_out = False
 
         self.credentials = get_credentials(self.username)
 
@@ -103,6 +104,9 @@ class AxesTestCase(TestCase):
     def create_log(self, **kwargs):
         return AccessLog.objects.create(**self.get_kwargs_with_defaults(**kwargs))
 
+    def create_failure_log(self, **kwargs):
+        return AccessFailureLog.objects.create(**self.get_kwargs_with_defaults(**kwargs))
+
     def reset(self, ip=None, username=None):
         return reset(ip, username)
 
diff --git a/tests/test_admin.py b/tests/test_admin.py
index be9c35c..d739095 100644
--- a/tests/test_admin.py
+++ b/tests/test_admin.py
@@ -5,7 +5,7 @@ from django.contrib import admin
 from django.test import override_settings
 
 import axes.admin
-from axes.models import AccessAttempt, AccessLog
+from axes.models import AccessAttempt, AccessLog, AccessFailureLog
 from tests.base import AxesTestCase
 
 
@@ -15,14 +15,18 @@ class AxesEnableAdminFlag(AxesTestCase):
             admin.site.unregister(AccessAttempt)
         with suppress(admin.sites.NotRegistered):
             admin.site.unregister(AccessLog)
+        with suppress(admin.sites.NotRegistered):
+            admin.site.unregister(AccessFailureLog)
 
     @override_settings(AXES_ENABLE_ADMIN=False)
     def test_disable_admin(self):
         reload(axes.admin)
         self.assertFalse(admin.site.is_registered(AccessAttempt))
         self.assertFalse(admin.site.is_registered(AccessLog))
+        self.assertFalse(admin.site.is_registered(AccessFailureLog))
 
     def test_enable_admin_by_default(self):
         reload(axes.admin)
         self.assertTrue(admin.site.is_registered(AccessAttempt))
         self.assertTrue(admin.site.is_registered(AccessLog))
+        self.assertTrue(admin.site.is_registered(AccessFailureLog))
diff --git a/tests/test_failures.py b/tests/test_failures.py
new file mode 100644
index 0000000..88c5edc
--- /dev/null
+++ b/tests/test_failures.py
@@ -0,0 +1,16 @@
+from axes.models import AccessFailureLog
+from tests.base import AxesTestCase
+from axes.helpers import get_failure_limit
+from django.test import override_settings
+
+@override_settings(AXES_ENABLE_ACCESS_FAILURE_LOG=True)
+class FailureLogTestCase(AxesTestCase):
+    def test_failure_log(self):
+        self.login(is_valid_username=True, is_valid_password=False)
+        self.assertEqual(AccessFailureLog.objects.count(), 1)
+        self.assertTrue(AccessFailureLog.objects.filter(username=self.VALID_USERNAME).exists())
+        self.assertTrue(AccessFailureLog.objects.filter(ip_address=self.ip_address).exists())
+
+    def test_failure_locked_out(self):
+        self.check_lockout()
+        self.assertEqual(AccessFailureLog.objects.filter(locked_out=True).count(), 1)
diff --git a/tests/test_handlers.py b/tests/test_handlers.py
index b7cc0c1..5ae36b0 100644
--- a/tests/test_handlers.py
+++ b/tests/test_handlers.py
@@ -12,7 +12,7 @@ from django.utils.timezone import timedelta
 from axes.conf import settings
 from axes.handlers.proxy import AxesProxyHandler
 from axes.helpers import get_client_str
-from axes.models import AccessAttempt, AccessLog
+from axes.models import AccessAttempt, AccessLog, AccessFailureLog
 from tests.base import AxesTestCase
 
 
@@ -212,6 +212,7 @@ class ResetAttemptsTestCase(AxesHandlerBaseTestCase):
     AXES_HANDLER="axes.handlers.database.AxesDatabaseHandler",
     AXES_COOLOFF_TIME=timedelta(seconds=2),
     AXES_RESET_ON_SUCCESS=True,
+    AXES_ENABLE_ACCESS_FAILURE_LOG=True,
 )
 @mark.xfail(
     python_implementation() == "PyPy",
@@ -240,6 +241,28 @@ class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase):
         self.assertEqual(1, AxesProxyHandler.reset_logs(age_days=42))
         self.assertEqual(AccessLog.objects.count(), 1)
 
+    def test_handler_reset_failure_logs(self):
+        self.create_failure_log()
+        self.assertEqual(1, AxesProxyHandler.reset_failure_logs())
+        self.assertFalse(AccessFailureLog.objects.count())
+
+    def test_handler_reset_failure_logs_older_than_42_days(self):
+        self.create_failure_log()
+
+        then = timezone.now() - timezone.timedelta(days=90)
+        with patch("django.utils.timezone.now", return_value=then):
+            self.create_failure_log()
+
+        self.assertEqual(AccessFailureLog.objects.count(), 2)
+        self.assertEqual(1, AxesProxyHandler.reset_failure_logs(age_days=42))
+        self.assertEqual(AccessFailureLog.objects.count(), 1)
+
+    def test_handler_remove_out_of_limit_failure_logs(self):
+        _more = 10
+        for i in range(settings.AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT + _more):
+            self.create_failure_log()
+        self.assertEqual(_more, AxesProxyHandler.remove_out_of_limit_failure_logs(username=self.username))
+
     @override_settings(AXES_RESET_ON_SUCCESS=True)
     def test_handler(self):
         self.check_handler()
diff --git a/tests/test_login.py b/tests/test_login.py
index bb0468a..07d5a45 100644
--- a/tests/test_login.py
+++ b/tests/test_login.py
@@ -3,8 +3,9 @@ Integration tests for the login handling.
 
 TODO: Clean up the tests in this module.
 """
-
+from datetime import timedelta
 from importlib import import_module
+from time import sleep
 
 from django.contrib.auth import get_user_model, login, logout
 from django.http import HttpRequest
@@ -12,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
+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
 
@@ -631,6 +632,46 @@ 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),
+        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
+        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 work
+        response = self.login(is_valid_username=True, is_valid_password=True)
+        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
 @override_settings(AXES_HANDLER="axes.handlers.cache.AxesCacheHandler")
diff --git a/tests/test_models.py b/tests/test_models.py
index 95aade1..6ba0f5b 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -4,7 +4,7 @@ from django.db.migrations.autodetector import MigrationAutodetector
 from django.db.migrations.executor import MigrationExecutor
 from django.db.migrations.state import ProjectState
 
-from axes.models import AccessAttempt, AccessLog
+from axes.models import AccessAttempt, AccessLog, AccessFailureLog
 from tests.base import AxesTestCase
 
 
@@ -16,6 +16,7 @@ class ModelsTestCase(AxesTestCase):
             failures_since_start=self.failures_since_start
         )
         self.access_log = AccessLog()
+        self.access_failure_log = AccessFailureLog()
 
     def test_access_attempt_str(self):
         self.assertIn("Access", str(self.access_attempt))
@@ -23,6 +24,9 @@ class ModelsTestCase(AxesTestCase):
     def test_access_log_str(self):
         self.assertIn("Access", str(self.access_log))
 
+    def test_access_failure_log_str(self):
+        self.assertIn("Failed", str(self.access_failure_log))
+
 
 class MigrationsTestCase(AxesTestCase):
     def test_missing_migrations(self):