Repository: jazzband/django-axes
Pull request #869 (2022-04-08 21:47:43)

Set permissions for GitHub actions
- Included permissions for the action. https://github.com/ossf/scorecard/blob/main/docs/checks.md#token-permissions

https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions

https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs

[Keeping your GitHub Actions and workflows secure Part 1: Preventing pwn requests](https://securitylab.github.com/research/github-actions-preventing-pwn-requests/)

 Restrict the GitHub token permissions only to the required ones; this way, even if the attackers will succeed in compromising your workflow, they won’t be able to do much.

Signed-off-by: naveensrinivasan <172697+naveensrinivasan@users.noreply.github.com>



Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 2ba83e6..54d834a 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -6,3 +6,9 @@ updates:
     interval: "daily"
     time: "12:00"
   open-pull-requests-limit: 10
+- package-ecosystem: "github-actions"
+  directory: "/"
+  schedule:
+    interval: "daily"
+    time: "12:00"
+  open-pull-requests-limit: 10
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f87cedd..619ad49 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -5,18 +5,21 @@ on:
     tags:
     - '*'
 
+permissions:
+  contents: read
+
 jobs:
   build:
     if: github.repository == 'jazzband/django-axes'
     runs-on: ubuntu-latest
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
         with:
           fetch-depth: 0
 
       - name: Set up Python
-        uses: actions/setup-python@v2
+        uses: actions/setup-python@v3
         with:
           python-version: 3.8
 
diff --git a/axes/admin.py b/axes/admin.py
index 93f3301..5f8a9c4 100644
--- a/axes/admin.py
+++ b/axes/admin.py
@@ -1,4 +1,5 @@
 from django.contrib import admin
+from django.http import HttpRequest
 from django.utils.translation import gettext_lazy as _
 
 from axes.conf import settings
@@ -39,7 +40,7 @@ class AccessAttemptAdmin(admin.ModelAdmin):
         "failures_since_start",
     ]
 
-    def has_add_permission(self, request):
+    def has_add_permission(self, request: HttpRequest) -> bool:
         return False
 
 
@@ -74,7 +75,7 @@ class AccessLogAdmin(admin.ModelAdmin):
         "logout_time",
     ]
 
-    def has_add_permission(self, request):
+    def has_add_permission(self, request: HttpRequest) -> bool:
         return False
 
 
@@ -109,7 +110,7 @@ class AccessFailureLogAdmin(admin.ModelAdmin):
         "locked_out",
     ]
 
-    def has_add_permission(self, request):
+    def has_add_permission(self, request: HttpRequest) -> bool:
         return False
 
 
diff --git a/axes/attempts.py b/axes/attempts.py
index a47eb60..ee057fa 100644
--- a/axes/attempts.py
+++ b/axes/attempts.py
@@ -1,7 +1,8 @@
 from logging import getLogger
-from typing import List
+from typing import List, Optional
 
 from django.db.models import QuerySet
+from django.http import HttpRequest
 from django.utils.timezone import datetime, now
 
 from axes.conf import settings
@@ -11,7 +12,7 @@ from axes.models import AccessAttempt
 log = getLogger(__name__)
 
 
-def get_cool_off_threshold(attempt_time: datetime = None) -> datetime:
+def get_cool_off_threshold(attempt_time: Optional[datetime] = None) -> datetime:
     """
     Get threshold for fetching access attempts from the database.
     """
@@ -27,7 +28,7 @@ def get_cool_off_threshold(attempt_time: datetime = None) -> datetime:
     return attempt_time - cool_off
 
 
-def filter_user_attempts(request, credentials: 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.
     """
@@ -44,7 +45,7 @@ def filter_user_attempts(request, credentials: dict = None) -> List[QuerySet]:
     return attempts_list
 
 
-def get_user_attempts(request, credentials: 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.
     """
@@ -62,7 +63,7 @@ def get_user_attempts(request, credentials: dict = None) -> List[QuerySet]:
     return [attempts.filter(attempt_time__gte=threshold) for attempts in attempts_list]
 
 
-def clean_expired_user_attempts(attempt_time: datetime = None) -> int:
+def clean_expired_user_attempts(attempt_time: Optional[datetime] = None) -> int:
     """
     Clean expired user attempts from the database.
     """
@@ -83,7 +84,7 @@ def clean_expired_user_attempts(attempt_time: datetime = None) -> int:
     return count
 
 
-def reset_user_attempts(request, credentials: 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 c337f89..b8186cb 100644
--- a/axes/backends.py
+++ b/axes/backends.py
@@ -1,5 +1,7 @@
+from typing import Optional
 from django.conf import settings
 from django.contrib.auth.backends import ModelBackend
+from django.http import HttpRequest
 
 from axes.exceptions import (
     AxesBackendPermissionDenied,
@@ -22,7 +24,7 @@ class AxesBackend(ModelBackend):
 
     @toggleable
     def authenticate(
-        self, request, username: str = None, password: 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/handlers/base.py b/axes/handlers/base.py
index 59b715d..f2643c4 100644
--- a/axes/handlers/base.py
+++ b/axes/handlers/base.py
@@ -1,5 +1,6 @@
 import re
 from abc import ABC, abstractmethod
+from typing import Optional
 
 from django.urls import reverse
 from django.urls.exceptions import NoReverseMatch
@@ -41,7 +42,7 @@ class AbstractAxesHandler(ABC):
         raise NotImplementedError("user_logged_out should be implemented")
 
     @abstractmethod
-    def get_failures(self, request, credentials: dict = None) -> int:
+    def get_failures(self, request, credentials: Optional[dict] = None) -> int:
         """
         Checks the number of failures associated to the given request and credentials.
 
@@ -65,7 +66,7 @@ class AxesBaseHandler:  # pylint: disable=unused-argument
     .. note:: This is a virtual class and **can not be used without specialization**.
     """
 
-    def is_allowed(self, request, credentials: dict = None) -> bool:
+    def is_allowed(self, request, credentials: Optional[dict] = None) -> bool:
         """
         Checks if the user is allowed to access or use given functionality such as a login view or authentication.
 
@@ -94,7 +95,7 @@ class AxesBaseHandler:  # pylint: disable=unused-argument
 
         return True
 
-    def is_blacklisted(self, request, credentials: dict = None) -> bool:
+    def is_blacklisted(self, request, credentials: Optional[dict] = None) -> bool:
         """
         Checks if the request or given credentials are blacklisted from access.
         """
@@ -104,7 +105,7 @@ class AxesBaseHandler:  # pylint: disable=unused-argument
 
         return False
 
-    def is_whitelisted(self, request, credentials: dict = None) -> bool:
+    def is_whitelisted(self, request, credentials: Optional[dict] = None) -> bool:
         """
         Checks if the request or given credentials are whitelisted for access.
         """
@@ -120,7 +121,7 @@ class AxesBaseHandler:  # pylint: disable=unused-argument
 
         return False
 
-    def is_locked(self, request, credentials: dict = None) -> bool:
+    def is_locked(self, request, credentials: Optional[dict] = None) -> bool:
         """
         Checks if the request or given credentials are locked.
         """
@@ -149,8 +150,8 @@ class AxesBaseHandler:  # pylint: disable=unused-argument
     def reset_attempts(
         self,
         *,
-        ip_address: str = None,
-        username: str = None,
+        ip_address: Optional[str] = None,
+        username: Optional[str] = None,
         ip_or_username: bool = False,
     ) -> int:
         """
@@ -163,7 +164,7 @@ class AxesBaseHandler:  # pylint: disable=unused-argument
         """
         return 0
 
-    def reset_logs(self, *, age_days: int = None) -> int:
+    def reset_logs(self, *, age_days: Optional[int] = None) -> int:
         """
         Resets access logs that are older than given number of days.
 
@@ -174,7 +175,7 @@ class AxesBaseHandler:  # pylint: disable=unused-argument
         """
         return 0
 
-    def reset_failure_logs(self, *, age_days: int = None) -> int:
+    def reset_failure_logs(self, *, age_days: Optional[int] = None) -> int:
         """
         Resets access failure logs that are older than given number of days.
 
@@ -186,7 +187,7 @@ class AxesBaseHandler:  # pylint: disable=unused-argument
         return 0
 
 
-    def remove_out_of_limit_failure_logs(self, *, username: str, limit: 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.
 
@@ -212,5 +213,5 @@ class AxesHandler(AbstractAxesHandler, AxesBaseHandler):
     def user_logged_out(self, sender, request, user, **kwargs):
         pass
 
-    def get_failures(self, request, credentials: dict = None) -> int:
+    def get_failures(self, request, credentials: Optional[dict] = None) -> int:
         return 0
diff --git a/axes/handlers/cache.py b/axes/handlers/cache.py
index 8bddae1..423c592 100644
--- a/axes/handlers/cache.py
+++ b/axes/handlers/cache.py
@@ -1,4 +1,5 @@
 from logging import getLogger
+from typing import Optional
 
 from axes.conf import settings
 from axes.handlers.base import AxesBaseHandler, AbstractAxesHandler
@@ -56,7 +57,7 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
 
         return count
 
-    def get_failures(self, request, credentials: dict = None) -> int:
+    def get_failures(self, request, credentials: Optional[dict] = None) -> int:
         cache_keys = get_client_cache_key(request, credentials)
         failure_count = max(
             self.cache.get(cache_key, default=0) for cache_key in cache_keys
diff --git a/axes/handlers/database.py b/axes/handlers/database.py
index 176c898..5ac0843 100644
--- a/axes/handlers/database.py
+++ b/axes/handlers/database.py
@@ -1,4 +1,5 @@
 from logging import getLogger
+from typing import Optional
 
 from django.db import transaction
 from django.db.models import F, Sum, Value, Q
@@ -36,8 +37,8 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
     def reset_attempts(
         self,
         *,
-        ip_address: str = None,
-        username: str = None,
+        ip_address: Optional[str] = None,
+        username: Optional[str] = None,
         ip_or_username: bool = False,
     ) -> int:
         attempts = AccessAttempt.objects.all()
@@ -55,7 +56,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
 
         return count
 
-    def reset_logs(self, *, age_days: int = None) -> int:
+    def reset_logs(self, *, age_days: Optional[int] = None) -> int:
         if age_days is None:
             count, _ = AccessLog.objects.all().delete()
             log.info("AXES: Reset all %d access logs from database.", count)
@@ -70,7 +71,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
 
         return count
 
-    def reset_failure_logs(self, *, age_days: int = None) -> int:
+    def reset_failure_logs(self, *, age_days: Optional[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)
@@ -89,7 +90,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
             self,
             *,
             username: str,
-            limit: int = settings.AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT) -> int:
+            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
@@ -99,7 +100,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
                 count += 1
         return count
 
-    def get_failures(self, request, credentials: dict = None) -> int:
+    def get_failures(self, request, credentials: Optional[dict] = None) -> int:
         attempts_list = get_user_attempts(request, credentials)
         attempt_count = max(
             (
diff --git a/axes/handlers/dummy.py b/axes/handlers/dummy.py
index 0e40ace..b8c3bbb 100644
--- a/axes/handlers/dummy.py
+++ b/axes/handlers/dummy.py
@@ -1,4 +1,5 @@
 from axes.handlers.base import AxesBaseHandler, AbstractAxesHandler
+from typing import Optional
 
 
 class AxesDummyHandler(AbstractAxesHandler, AxesBaseHandler):
@@ -6,7 +7,7 @@ class AxesDummyHandler(AbstractAxesHandler, AxesBaseHandler):
     Signal handler implementation that does nothing and can be used to disable signal processing.
     """
 
-    def is_allowed(self, request, credentials: dict = None) -> bool:
+    def is_allowed(self, request, credentials: Optional[dict] = None) -> bool:
         return True
 
     def user_login_failed(self, sender, credentials: dict, request=None, **kwargs):
@@ -18,5 +19,5 @@ class AxesDummyHandler(AbstractAxesHandler, AxesBaseHandler):
     def user_logged_out(self, sender, request, user, **kwargs):
         pass
 
-    def get_failures(self, request, credentials: dict = None) -> int:
+    def get_failures(self, request, credentials: Optional[dict] = None) -> int:
         return 0
diff --git a/axes/handlers/proxy.py b/axes/handlers/proxy.py
index 8c0a3f3..1adebe1 100644
--- a/axes/handlers/proxy.py
+++ b/axes/handlers/proxy.py
@@ -1,4 +1,5 @@
 from logging import getLogger
+from typing import Optional
 
 from django.utils.module_loading import import_string
 from django.utils.timezone import now
@@ -46,8 +47,8 @@ class AxesProxyHandler(AbstractAxesHandler, AxesBaseHandler):
     def reset_attempts(
         cls,
         *,
-        ip_address: str = None,
-        username: str = None,
+        ip_address: Optional[str] = None,
+        username: Optional[str] = None,
         ip_or_username: bool = False,
     ) -> int:
         return cls.get_implementation().reset_attempts(
@@ -55,15 +56,15 @@ class AxesProxyHandler(AbstractAxesHandler, AxesBaseHandler):
         )
 
     @classmethod
-    def reset_logs(cls, *, age_days: int = None) -> int:
+    def reset_logs(cls, *, age_days: Optional[int] = None) -> int:
         return cls.get_implementation().reset_logs(age_days=age_days)
 
     @classmethod
-    def reset_failure_logs(cls, *, age_days: int = None) -> int:
+    def reset_failure_logs(cls, *, age_days: Optional[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:
+    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
@@ -90,17 +91,17 @@ class AxesProxyHandler(AbstractAxesHandler, AxesBaseHandler):
             request.axes_credentials = None
 
     @classmethod
-    def is_locked(cls, request, credentials: dict = None) -> bool:
+    def is_locked(cls, request, credentials: Optional[dict] = None) -> bool:
         cls.update_request(request)
         return cls.get_implementation().is_locked(request, credentials)
 
     @classmethod
-    def is_allowed(cls, request, credentials: dict = None) -> bool:
+    def is_allowed(cls, request, credentials: Optional[dict] = None) -> bool:
         cls.update_request(request)
         return cls.get_implementation().is_allowed(request, credentials)
 
     @classmethod
-    def get_failures(cls, request, credentials: dict = None) -> int:
+    def get_failures(cls, request, credentials: Optional[dict] = None) -> int:
         cls.update_request(request)
         return cls.get_implementation().get_failures(request, credentials)
 
diff --git a/axes/helpers.py b/axes/helpers.py
index 3061614..e85c8d7 100644
--- a/axes/helpers.py
+++ b/axes/helpers.py
@@ -59,6 +59,8 @@ def get_cool_off() -> Optional[timedelta]:
 
     if isinstance(cool_off, int):
         return timedelta(hours=cool_off)
+    if isinstance(cool_off, float):
+        return timedelta(minutes=cool_off * 60)
     if isinstance(cool_off, str):
         return import_string(cool_off)()
     if callable(cool_off):
@@ -90,7 +92,7 @@ def get_cool_off_iso8601(delta: timedelta) -> str:
     return f"P{days_str}"
 
 
-def get_credentials(username: str = None, **kwargs) -> dict:
+def get_credentials(username: Optional[str] = None, **kwargs) -> dict:
     """
     Calculate credentials for Axes to use internally from given username and kwargs.
 
@@ -103,7 +105,7 @@ def get_credentials(username: str = None, **kwargs) -> dict:
     return credentials
 
 
-def get_client_username(request, credentials: 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.
 
@@ -144,7 +146,7 @@ def get_client_username(request, credentials: dict = None) -> str:
     return request_data.get(settings.AXES_USERNAME_FORM_FIELD, None)
 
 
-def get_client_ip_address(request) -> str:
+def get_client_ip_address(request: HttpRequest) -> str:
     """
     Get client IP address as configured by the user.
 
@@ -163,15 +165,15 @@ def get_client_ip_address(request) -> str:
     return client_ip_address
 
 
-def get_client_user_agent(request) -> str:
+def get_client_user_agent(request: HttpRequest) -> str:
     return request.META.get("HTTP_USER_AGENT", "<unknown>")[:255]
 
 
-def get_client_path_info(request) -> str:
+def get_client_path_info(request: HttpRequest) -> str:
     return request.META.get("PATH_INFO", "<unknown>")[:255]
 
 
-def get_client_http_accept(request) -> str:
+def get_client_http_accept(request: HttpRequest) -> str:
     return request.META.get("HTTP_ACCEPT", "<unknown>")[:1025]
 
 
@@ -218,7 +220,7 @@ def make_cache_key_list(filter_kwargs_list):
 
 
 def get_client_cache_key(
-    request_or_attempt: Union[HttpRequest, AccessBase], credentials: dict = None
+    request_or_attempt: Union[HttpRequest, AccessBase], credentials: Optional[dict] = None
 ) -> str:
     """
     Build cache key name from request or AccessAttempt object.
@@ -342,7 +344,7 @@ def get_query_str(query: Type[QueryDict], max_length: int = 1024) -> str:
     return query_str[:max_length]
 
 
-def get_failure_limit(request, credentials) -> int:
+def get_failure_limit(request: HttpRequest, credentials) -> int:
     if callable(settings.AXES_FAILURE_LIMIT):
         return settings.AXES_FAILURE_LIMIT(  # pylint: disable=not-callable
             request, credentials
@@ -360,7 +362,7 @@ def get_lockout_message() -> str:
     return settings.AXES_PERMALOCK_MESSAGE
 
 
-def get_lockout_response(request, credentials: 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
@@ -430,7 +432,7 @@ def is_ip_address_in_blacklist(ip_address: str) -> bool:
     )
 
 
-def is_client_ip_address_whitelisted(request):
+def is_client_ip_address_whitelisted(request: HttpRequest):
     """
     Check if the given request refers to a whitelisted IP.
     """
@@ -448,7 +450,7 @@ def is_client_ip_address_whitelisted(request):
     return False
 
 
-def is_client_ip_address_blacklisted(request) -> bool:
+def is_client_ip_address_blacklisted(request: HttpRequest) -> bool:
     """
     Check if the given request refers to a blacklisted IP.
     """
@@ -464,7 +466,7 @@ def is_client_ip_address_blacklisted(request) -> bool:
     return False
 
 
-def is_client_method_whitelisted(request) -> bool:
+def is_client_method_whitelisted(request: HttpRequest) -> bool:
     """
     Check if the given request uses a whitelisted method.
     """
@@ -475,7 +477,7 @@ def is_client_method_whitelisted(request) -> bool:
     return False
 
 
-def is_user_attempt_whitelisted(request, credentials: 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/middleware.py b/axes/middleware.py
index 45fea61..1f70ce9 100644
--- a/axes/middleware.py
+++ b/axes/middleware.py
@@ -1,6 +1,7 @@
 from typing import Callable
 
 from django.conf import settings
+from django.http import HttpRequest, HttpResponse
 
 from axes.helpers import get_lockout_response
 
@@ -29,10 +30,10 @@ class AxesMiddleware:
     - ``AXES_PERMALOCK_MESSAGE``.
     """
 
-    def __init__(self, get_response: Callable):
+    def __init__(self, get_response: Callable) -> None:
         self.get_response = get_response
 
-    def __call__(self, request):
+    def __call__(self, request: HttpRequest) -> HttpResponse:
         response = self.get_response(request)
 
         if settings.AXES_ENABLED:
diff --git a/axes/utils.py b/axes/utils.py
index 2cdd9d7..824fc9c 100644
--- a/axes/utils.py
+++ b/axes/utils.py
@@ -17,7 +17,7 @@ from axes.helpers import get_client_ip_address
 log = getLogger(__name__)
 
 
-def reset(ip: str = None, username: 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/docs/4_configuration.rst b/docs/4_configuration.rst
index 8d2b552..185837c 100644
--- a/docs/4_configuration.rst
+++ b/docs/4_configuration.rst
@@ -28,9 +28,12 @@ The following ``settings.py`` options are available for customizing Axes behavio
   Default: ``True``
 * ``AXES_COOLOFF_TIME``: If set, defines a period of inactivity after which
   old failed login attempts will be cleared.
-  Can be set to a Python timedelta object, an integer, a callable,
+  Can be set to a Python timedelta object, an integer, a float, a callable,
   or a string path to a callable which takes no arguments.
-  If an integer, will be interpreted as a number of hours.
+  If an integer or float, will be interpreted as a number of hours:
+  ``AXES_COOLOFF_TIME = 2`` 2 hours
+  ``AXES_COOLOFF_TIME = 2.0`` 2 hours, 120 minutes
+  ``AXES_COOLOFF_TIME = 1.7`` 1.7 hours, 102 minutes, 6120 seconds
   Default: ``None``
 * ``AXES_ONLY_ADMIN_SITE``: If ``True``, lock is only enabled for admin site.
   Admin site is determined by checking request path against the path of ``"admin:index"`` view.
diff --git a/pyproject.toml b/pyproject.toml
index d8b8b66..2ff2e5b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,7 +13,7 @@ envlist =
     py{37,38,39,310,py3}-dj32
     py{38,39,310,py3}-dj40
     py{38,39,310}-djmain
-    py310-djqa
+    py38-djqa
 
 [gh-actions]
 python =
diff --git a/requirements-qa.txt b/requirements-qa.txt
index a920369..5688460 100644
--- a/requirements-qa.txt
+++ b/requirements-qa.txt
@@ -1,4 +1,4 @@
 black==22.3.0
-mypy==0.942
+mypy==0.950
 prospector==1.7.7
 types-pkg_resources  # Type stub
diff --git a/requirements.txt b/requirements.txt
index 1133462..c2f5816 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,4 +2,4 @@
 -r requirements-qa.txt
 -r requirements-test.txt
 sphinx_rtd_theme==1.0.0
-tox==3.24.5
+tox==3.25.0


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index e43c1f2..4f38195 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -2,6 +2,9 @@ name: Test
 
 on: [push, pull_request]
 
+permissions:
+  contents: read
+
 jobs:
   build:
     name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }})
@@ -37,10 +40,10 @@ jobs:
             django-version: 'main'
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v2
+      uses: actions/setup-python@v3
       with:
         python-version: ${{ matrix.python-version }}
 
@@ -50,7 +53,7 @@ jobs:
         echo "::set-output name=dir::$(pip cache dir)"
 
     - name: Cache
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
         path: ${{ steps.pip-cache.outputs.dir }}
         key:
@@ -70,6 +73,6 @@ jobs:
         DJANGO: ${{ matrix.django-version }}
 
     - name: Upload coverage
-      uses: codecov/codecov-action@v1
+      uses: codecov/codecov-action@v3
       with:
         name: Python ${{ matrix.python-version }}
diff --git a/axes/handlers/test.py b/axes/handlers/test.py
index c345104..bea66b3 100644
--- a/axes/handlers/test.py
+++ b/axes/handlers/test.py
@@ -1,4 +1,5 @@
 from axes.handlers.base import AxesHandler
+from typing import Optional
 
 
 class AxesTestHandler(AxesHandler):  # pylint: disable=unused-argument
@@ -9,17 +10,17 @@ class AxesTestHandler(AxesHandler):  # pylint: disable=unused-argument
     def reset_attempts(
         self,
         *,
-        ip_address: str = None,
-        username: str = None,
+        ip_address: Optional[str] = None,
+        username: Optional[str] = None,
         ip_or_username: bool = False,
     ) -> int:
         return 0
 
-    def reset_logs(self, *, age_days: int = None) -> int:
+    def reset_logs(self, *, age_days: Optional[int] = None) -> int:
         return 0
 
-    def is_allowed(self, request, credentials: dict = None) -> bool:
+    def is_allowed(self, request, credentials: Optional[dict] = None) -> bool:
         return True
 
-    def get_failures(self, request, credentials: dict = None) -> int:
+    def get_failures(self, request, credentials: Optional[dict] = None) -> int:
         return 0
diff --git a/requirements-test.txt b/requirements-test.txt
index 6657e36..da52f2f 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -1,6 +1,6 @@
 -e .
 coverage==6.3.2
-pytest==7.1.1
+pytest==7.1.2
 pytest-cov==3.0.0
 pytest-django==4.5.2
 pytest-subtests==0.7.0
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index e17937c..f17bee0 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -670,6 +670,18 @@ class AxesCoolOffTestCase(AxesTestCase):
     def test_get_cool_off_int(self):
         self.assertEqual(get_cool_off(), timedelta(hours=2))
 
+    @override_settings(AXES_COOLOFF_TIME=2.0)
+    def test_get_cool_off_int(self):
+        self.assertEqual(get_cool_off(), timedelta(minutes=120))
+
+    @override_settings(AXES_COOLOFF_TIME=0.25)
+    def test_get_cool_off_int(self):
+        self.assertEqual(get_cool_off(), timedelta(minutes=15))
+
+    @override_settings(AXES_COOLOFF_TIME=1.7)
+    def test_get_cool_off_int(self):
+        self.assertEqual(get_cool_off(), timedelta(seconds=6120))
+
     @override_settings(AXES_COOLOFF_TIME=lambda: timedelta(seconds=30))
     def test_get_cool_off_callable(self):
         self.assertEqual(get_cool_off(), timedelta(seconds=30))