Repository: jazzband/django-axes
Pull request #889 (2022-05-15 12:09:58)

Fix QA and test issues
Continue using MD5 hashing in Axes test settings
as it offers better performance for test runs
without compromising security for users

Fixes issues raised by #888 

codecov[bot]: # [Codecov](https://codecov.io/gh/jazzband/django-axes/pull/889?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband) Report
> Merging [#889](https://codecov.io/gh/jazzband/django-axes/pull/889?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband) (a471929) into [master](https://codecov.io/gh/jazzband/django-axes/commit/68a4827870ff778b0c54beef1aad8a5d69dcdfd8?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband) (68a4827) will **not change** coverage.
> The diff coverage is `100.00%`.

```diff
@@           Coverage Diff           @@
##           master     #889   +/-   ##
=======================================
  Coverage   96.15%   96.15%           
=======================================
  Files          34       34           
  Lines        1040     1040           
  Branches      184      182    -2     
=======================================
  Hits         1000     1000           
  Misses         34       34           
  Partials        6        6           
```


| [Impacted Files](https://codecov.io/gh/jazzband/django-axes/pull/889?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband) | Coverage Δ | |
|---|---|---|
| [axes/helpers.py](https://codecov.io/gh/jazzband/django-axes/pull/889/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband#diff-YXhlcy9oZWxwZXJzLnB5) | `97.20% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/jazzband/django-axes/pull/889?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/889?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband). Last update [68a4827...a471929](https://codecov.io/gh/jazzband/django-axes/pull/889?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).


Patch:
diff --git a/.prospector.yaml b/.prospector.yaml
index 9020238..219f6ac 100644
--- a/.prospector.yaml
+++ b/.prospector.yaml
@@ -2,6 +2,6 @@ ignore-paths:
   - docs
   - axes/migrations
 
-pep8:
+pycodestyle:
   options:
     max-line-length: 142
diff --git a/axes/apps.py b/axes/apps.py
index 0f8413a..5ef2612 100644
--- a/axes/apps.py
+++ b/axes/apps.py
@@ -38,7 +38,11 @@ class AppConfig(apps.AppConfig):
                 mode = "blocking by username or IP"
             else:
                 mode = "blocking by IP only"
-            log.info("AXES: BEGIN version %s, %s", get_distribution("django-axes").version, mode)
+            log.info(
+                "AXES: BEGIN version %s, %s",
+                get_distribution("django-axes").version,
+                mode,
+            )
 
     def ready(self):
         self.initialize()
diff --git a/axes/attempts.py b/axes/attempts.py
index ee057fa..fd6d552 100644
--- a/axes/attempts.py
+++ b/axes/attempts.py
@@ -28,7 +28,9 @@ def get_cool_off_threshold(attempt_time: Optional[datetime] = None) -> datetime:
     return attempt_time - cool_off
 
 
-def filter_user_attempts(request: HttpRequest, credentials: Optional[dict] = None) -> List[QuerySet]:
+def filter_user_attempts(
+    request: HttpRequest, credentials: Optional[dict] = None
+) -> List[QuerySet]:
     """
     Return a list querysets of AccessAttempts that match the given request and credentials.
     """
@@ -45,7 +47,9 @@ def filter_user_attempts(request: HttpRequest, credentials: Optional[dict] = Non
     return attempts_list
 
 
-def get_user_attempts(request: HttpRequest, credentials: Optional[dict] = None) -> List[QuerySet]:
+def get_user_attempts(
+    request: HttpRequest, credentials: Optional[dict] = None
+) -> List[QuerySet]:
     """
     Get list of querysets with valid user attempts that match the given request and credentials.
     """
@@ -84,7 +88,9 @@ def clean_expired_user_attempts(attempt_time: Optional[datetime] = None) -> int:
     return count
 
 
-def reset_user_attempts(request: HttpRequest, credentials: Optional[dict] = None) -> int:
+def reset_user_attempts(
+    request: HttpRequest, credentials: Optional[dict] = None
+) -> int:
     """
     Reset all user attempts that match the given request and credentials.
     """
diff --git a/axes/backends.py b/axes/backends.py
index b8186cb..e077e29 100644
--- a/axes/backends.py
+++ b/axes/backends.py
@@ -24,7 +24,11 @@ class AxesBackend(ModelBackend):
 
     @toggleable
     def authenticate(
-        self, request: HttpRequest, username: Optional[str] = None, password: Optional[str] = None, **kwargs: dict
+        self,
+        request: HttpRequest,
+        username: Optional[str] = None,
+        password: Optional[str] = None,
+        **kwargs: dict,
     ):
         """
         Checks user lockout status and raises an exception if user is not allowed to log in.
diff --git a/axes/conf.py b/axes/conf.py
index ad7ccbe..745024f 100644
--- a/axes/conf.py
+++ b/axes/conf.py
@@ -57,9 +57,13 @@ 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_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_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 f2643c4..92b50b1 100644
--- a/axes/handlers/base.py
+++ b/axes/handlers/base.py
@@ -186,8 +186,9 @@ class AxesBaseHandler:  # pylint: disable=unused-argument
         """
         return 0
 
-
-    def remove_out_of_limit_failure_logs(self, *, username: str, limit: Optional[int] = None) -> int:
+    def remove_out_of_limit_failure_logs(
+        self, *, username: str, limit: Optional[int] = None
+    ) -> int:
         """Remove access failure logs that are over
         AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT for user username.
 
@@ -199,6 +200,7 @@ class AxesBaseHandler:  # pylint: disable=unused-argument
         """
         return 0
 
+
 class AxesHandler(AbstractAxesHandler, AxesBaseHandler):
     """
     Signal bare handler implementation without any storage backend.
diff --git a/axes/handlers/database.py b/axes/handlers/database.py
index 5ac0843..4e4dc67 100644
--- a/axes/handlers/database.py
+++ b/axes/handlers/database.py
@@ -87,10 +87,11 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
         return count
 
     def remove_out_of_limit_failure_logs(
-            self,
-            *,
-            username: str,
-            limit: Optional[int] = settings.AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT) -> int:
+        self,
+        *,
+        username: str,
+        limit: Optional[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
@@ -252,7 +253,6 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
                 )
                 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 1adebe1..3933013 100644
--- a/axes/handlers/proxy.py
+++ b/axes/handlers/proxy.py
@@ -1,3 +1,6 @@
+# pylint: disable=arguments-differ
+# pylint generates false negatives from proxy class method overrides
+
 from logging import getLogger
 from typing import Optional
 
@@ -64,8 +67,12 @@ class AxesProxyHandler(AbstractAxesHandler, AxesBaseHandler):
         return cls.get_implementation().reset_failure_logs(age_days=age_days)
 
     @classmethod
-    def remove_out_of_limit_failure_logs(cls, *, username: str, limit: Optional[int] = None) -> int:
-        return cls.get_implementation().remove_out_of_limit_failure_logs(username=username)
+    def remove_out_of_limit_failure_logs(
+        cls, *, username: str, limit: Optional[int] = None
+    ) -> int:
+        return cls.get_implementation().remove_out_of_limit_failure_logs(
+            username=username
+        )
 
     @staticmethod
     def update_request(request):
diff --git a/axes/helpers.py b/axes/helpers.py
index e85c8d7..879a784 100644
--- a/axes/helpers.py
+++ b/axes/helpers.py
@@ -1,5 +1,5 @@
 from datetime import timedelta
-from hashlib import md5
+from hashlib import sha256
 from logging import getLogger
 from string import Template
 from typing import Callable, Optional, Type, Union
@@ -105,7 +105,9 @@ def get_credentials(username: Optional[str] = None, **kwargs) -> dict:
     return credentials
 
 
-def get_client_username(request: HttpRequest, credentials: Optional[dict] = None) -> str:
+def get_client_username(
+    request: HttpRequest, credentials: Optional[dict] = None
+) -> str:
     """
     Resolve client username from the given request or credentials if supplied.
 
@@ -214,13 +216,14 @@ def make_cache_key_list(filter_kwargs_list):
         cache_key_components = "".join(
             value for value in filter_kwargs.values() if value
         )
-        cache_key_digest = md5(cache_key_components.encode()).hexdigest()
+        cache_key_digest = sha256(cache_key_components.encode()).hexdigest()
         cache_keys.append(f"axes-{cache_key_digest}")
     return cache_keys
 
 
 def get_client_cache_key(
-    request_or_attempt: Union[HttpRequest, AccessBase], credentials: Optional[dict] = None
+    request_or_attempt: Union[HttpRequest, AccessBase],
+    credentials: Optional[dict] = None,
 ) -> str:
     """
     Build cache key name from request or AccessAttempt object.
@@ -362,7 +365,9 @@ def get_lockout_message() -> str:
     return settings.AXES_PERMALOCK_MESSAGE
 
 
-def get_lockout_response(request: HttpRequest, credentials: Optional[dict] = None) -> HttpResponse:
+def get_lockout_response(
+    request: HttpRequest, credentials: Optional[dict] = None
+) -> HttpResponse:
     if settings.AXES_LOCKOUT_CALLABLE:
         if callable(settings.AXES_LOCKOUT_CALLABLE):
             return settings.AXES_LOCKOUT_CALLABLE(  # pylint: disable=not-callable
@@ -477,7 +482,9 @@ def is_client_method_whitelisted(request: HttpRequest) -> bool:
     return False
 
 
-def is_user_attempt_whitelisted(request: HttpRequest, credentials: Optional[dict] = None) -> bool:
+def is_user_attempt_whitelisted(
+    request: HttpRequest, credentials: Optional[dict] = None
+) -> bool:
     """
     Check if the given request or credentials refer to a whitelisted username.
 
diff --git a/axes/migrations/0008_accessfailurelog.py b/axes/migrations/0008_accessfailurelog.py
index c7956f4..ac7c549 100644
--- a/axes/migrations/0008_accessfailurelog.py
+++ b/axes/migrations/0008_accessfailurelog.py
@@ -6,25 +6,64 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('axes', '0007_alter_accessattempt_unique_together'),
+        ("axes", "0007_alter_accessattempt_unique_together"),
     ]
 
     operations = [
         migrations.CreateModel(
-            name='AccessFailureLog',
+            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')),
+                (
+                    "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',
+                "verbose_name": "access failure",
+                "verbose_name_plural": "access failures",
             },
         ),
     ]
diff --git a/axes/models.py b/axes/models.py
index 58e5dbd..1b42141 100644
--- a/axes/models.py
+++ b/axes/models.py
@@ -22,10 +22,12 @@ class AccessBase(models.Model):
 
 
 class AccessFailureLog(AccessBase):
-    locked_out = models.BooleanField(_("Access lock out"), null=False, blank=True, default=False)
+    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 ''
+        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:
diff --git a/axes/utils.py b/axes/utils.py
index 824fc9c..55c82ef 100644
--- a/axes/utils.py
+++ b/axes/utils.py
@@ -17,7 +17,9 @@ from axes.helpers import get_client_ip_address
 log = getLogger(__name__)
 
 
-def reset(ip: Optional[str] = None, username: Optional[str] = None, ip_or_username=False) -> int:
+def reset(
+    ip: Optional[str] = None, username: Optional[str] = None, ip_or_username=False
+) -> int:
     """
     Reset records that match IP or username, and return the count of removed attempts.
 
diff --git a/pyproject.toml b/pyproject.toml
index fd32ec4..1deebea 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,10 +10,10 @@ DJANGO_SETTINGS_MODULE = "tests.settings"
 legacy_tox_ini = """
 [tox]
 envlist =
-    py{37,38,39,310,py3}-dj32
-    py{38,39,310,py3}-dj40
-    py{38,39,310}-djmain
-    py38-djqa
+    py{37,38,39,310,py38}-dj32
+    py{38,39,310,py38}-dj40
+    py{38,39,310,py38}-djmain
+    py310-djqa
 
 [gh-actions]
 python =
@@ -21,7 +21,7 @@ python =
     3.8: py38
     3.9: py39
     3.10: py310
-    pypy-3: pypy3
+    pypy-3.8: pypy38
 
 [gh-actions:env]
 DJANGO =


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 4f38195..82b5fdd 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -13,11 +13,11 @@ jobs:
       fail-fast: false
       max-parallel: 5
       matrix:
-        python-version: ['3.7', '3.8', '3.9', '3.10', 'pypy-3.8-v7.3.7']
-        django-version: ['3.2', '4.0', 'main']
+        python-version: ['3.7', '3.8', '3.9', '3.10', 'pypy-3.8']
+        django-version: ['3.2', '4.0']
         include:
           # Tox configuration for QA environment
-          - python-version: '3.8'
+          - python-version: '3.10'
             django-version: 'qa'
           # Django main
           - python-version: '3.8'
diff --git a/requirements-test.txt b/requirements-test.txt
index 1440549..da52f2f 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -1,5 +1,5 @@
 -e .
-coverage==6.3.3
+coverage==6.3.2
 pytest==7.1.2
 pytest-cov==3.0.0
 pytest-django==4.5.2
diff --git a/tests/settings.py b/tests/settings.py
index 43de0b4..949aad8 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -24,6 +24,9 @@ AUTHENTICATION_BACKENDS = [
     "django.contrib.auth.backends.ModelBackend",
 ]
 
+# Use MD5 for tests as it is considerably faster than other options
+# note that this should never be used in any online setting
+# where users actually log in to the system due to easy exploitability
 PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
 
 ROOT_URLCONF = "tests.urls"
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index f17bee0..30a216d 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -1,5 +1,5 @@
 from datetime import timedelta
-from hashlib import md5
+from hashlib import sha256
 from unittest.mock import patch
 
 from django.contrib.auth import get_user_model
@@ -353,7 +353,7 @@ class ClientCacheKeyTestCase(AxesTestCase):
         Test the cache key format.
         """
 
-        cache_hash_digest = md5(self.ip_address.encode()).hexdigest()
+        cache_hash_digest = sha256(self.ip_address.encode()).hexdigest()
         cache_hash_key = f"axes-{cache_hash_digest}"
 
         # Getting cache key from request
@@ -385,7 +385,7 @@ class ClientCacheKeyTestCase(AxesTestCase):
 
         empty_ip_address = ""
 
-        cache_hash_digest = md5(empty_ip_address.encode()).hexdigest()
+        cache_hash_digest = sha256(empty_ip_address.encode()).hexdigest()
         cache_hash_key = f"axes-{cache_hash_digest}"
 
         # Getting cache key from request
@@ -418,7 +418,7 @@ class ClientCacheKeyTestCase(AxesTestCase):
         """
 
         ip_address = self.ip_address
-        cache_hash_digest = md5(ip_address.encode()).hexdigest()
+        cache_hash_digest = sha256(ip_address.encode()).hexdigest()
         cache_hash_key = f"axes-{cache_hash_digest}"
 
         # Getting cache key from request