Repository: jazzband/django-axes
Pull request #862 (2022-03-15 12:11:10)

Failures logs
# Issue
The cool off time is normally used to lockout the user. But there is an unattended behaviour, it is also used to clean expired attempts (attempts that are older than the cool off time threshold).

# Needs
But how is our use case:
* Users MUST be locked-out during a grace period
* Users MUST see a log with every attempts to be informed of attack attempts
* Attempts database MUST never be cleaned automatically
* Attempts MUST be manually cleaned by an administrative human

# Fix-up with this PR
This PR add a new class model named `AccessFailureLog` that record every failed logins, like `AccessLog` do with successful logins.

codecov[bot]: # [Codecov](https://codecov.io/gh/jazzband/django-axes/pull/862?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband) Report
> Merging [#862](https://codecov.io/gh/jazzband/django-axes/pull/862?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband) (05c69b1) into [master](https://codecov.io/gh/jazzband/django-axes/commit/6df6f60ff8bf78cb7aff9cc9acb009536991c8fb?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband) (6df6f60) will **decrease** coverage by `1.21%`.
> The diff coverage is `64.86%`.

```diff
@@            Coverage Diff             @@
##           master     #862      +/-   ##
==========================================
- Coverage   97.15%   95.94%   -1.22%     
==========================================
  Files          32       34       +2     
  Lines         950      986      +36     
  Branches      168      173       +5     
==========================================
+ Hits          923      946      +23     
- Misses         21       34      +13     
  Partials        6        6              
```


| [Impacted Files](https://codecov.io/gh/jazzband/django-axes/pull/862?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband) | Coverage Δ | |
|---|---|---|
| [...xes/management/commands/axes\_reset\_failure\_logs.py](https://codecov.io/gh/jazzband/django-axes/pull/862/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband#diff-YXhlcy9tYW5hZ2VtZW50L2NvbW1hbmRzL2F4ZXNfcmVzZXRfZmFpbHVyZV9sb2dzLnB5) | `0.00% <0.00%> (ø)` | |
| [axes/models.py](https://codecov.io/gh/jazzband/django-axes/pull/862/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband#diff-YXhlcy9tb2RlbHMucHk=) | `94.73% <75.00%> (-5.27%)` | :arrow_down: |
| [axes/handlers/database.py](https://codecov.io/gh/jazzband/django-axes/pull/862/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband#diff-YXhlcy9oYW5kbGVycy9kYXRhYmFzZS5weQ==) | `100.00% <100.00%> (ø)` | |
| [axes/handlers/proxy.py](https://codecov.io/gh/jazzband/django-axes/pull/862/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband#diff-YXhlcy9oYW5kbGVycy9wcm94eS5weQ==) | `97.26% <100.00%> (+0.11%)` | :arrow_up: |
| [axes/migrations/0008\_accessfailurelog.py](https://codecov.io/gh/jazzband/django-axes/pull/862/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband#diff-YXhlcy9taWdyYXRpb25zLzAwMDhfYWNjZXNzZmFpbHVyZWxvZy5weQ==) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/jazzband/django-axes/pull/862?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/jazzband/django-axes/pull/862?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband). Last update [6df6f60...05c69b1](https://codecov.io/gh/jazzband/django-axes/pull/862?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband).

p1-gdd: You're right, this was not covered. I complete with your recommendations and push.
p1-gdd: I think this is a begin. Users will probably find some new kind of use case to improve this PR.

I've base our branch on version 5.28.0 as we DO still use Python 3.6 branch, even if this PR is master based.

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/conf.py b/axes/conf.py
index 67dc2d0..63d6ce8 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"
 )
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/database.py b/axes/handlers/database.py
index fcb9711..d2fc1a7 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.")
@@ -192,6 +223,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..960d820 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):
         """
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..f4abac8 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.
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_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):