New Upstream Release - django-axes

Ready changes

Summary

Merged new upstream version: 6.0.1 (was: 5.40.1).

Resulting package

Built on 2023-06-02T23:50 (took 6m30s)

The resulting binary packages can be installed (if you have the apt repository enabled) by running one of:

apt install -t fresh-releases python3-django-axes-docapt install -t fresh-releases python3-django-axes

Lintian Result

Diff

diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ff22dfc..11f835a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -36,7 +36,7 @@ jobs:
 
       - name: Upload packages to Jazzband
         if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
-        uses: pypa/gh-action-pypi-publish@master
+        uses: pypa/gh-action-pypi-publish@release/v1
         with:
           user: jazzband
           password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 5972a39..e687b0a 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -13,36 +13,31 @@ jobs:
       fail-fast: false
       max-parallel: 5
       matrix:
-        python-version: ['3.7', '3.8', '3.9', '3.10', 'pypy-3.8']
-        django-version: ['3.2', '4.0', '4.1']
+        python-version: ['3.8', '3.9', '3.10', '3.11']
+        django-version: ['3.2', '4.1', '4.2']
         include:
           # Tox configuration for QA environment
-          - python-version: '3.10'
+          - python-version: '3.11'
             django-version: 'qa'
           # Django main
-          - python-version: '3.8'
+          - python-version: '3.11'
             django-version: 'main'
             experimental: true
-          - python-version: '3.9'
-            django-version: 'main'
-            experimental: true
-          - python-version: '3.10'
-            django-version: 'main'
+          # PyPy 3.8
+          - python-version: 'pypy-3.8'
+            django-version: '3.2'
             experimental: true
           - python-version: 'pypy-3.8'
             django-version: '4.1'
             experimental: true
           - python-version: 'pypy-3.8'
-            django-version: 'main'
+            django-version: '4.2'
             experimental: true
         exclude:
-          # Exclude Python 3.7 for Django 4.0 and Django main
-          - python-version: '3.7'
-            django-version: '4.0'
-          - python-version: '3.7'
-            django-version: '4.1'
-          - python-version: '3.7'
-            django-version: 'main'
+          # Exclude Python 3.11 for Django 3.2 and Django 4.0
+          - python-version: '3.11'
+            django-version: '3.2'
+
 
     steps:
     - uses: actions/checkout@v3
diff --git a/.gitignore b/.gitignore
index 78c025f..bdf9645 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,4 +16,11 @@ docs/_build
 test.db
 .eggs
 pip-wheel-metadata
-.vscode/
\ No newline at end of file
+.vscode/
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
diff --git a/.prospector.yaml b/.prospector.yaml
index 219f6ac..0753272 100644
--- a/.prospector.yaml
+++ b/.prospector.yaml
@@ -5,3 +5,11 @@ ignore-paths:
 pycodestyle:
   options:
     max-line-length: 142
+
+pylint:
+  disable:
+    - django-not-configured
+
+pyflakes:
+  disable:
+    - F401
diff --git a/CHANGES.rst b/CHANGES.rst
index 2b27ba6..382e681 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -3,6 +3,73 @@ Changes
 =======
 
 
+6.0.1 (2023-05-17)
+------------------
+
+- Fine-tune CI pipelines and RTD build requirements.
+  [aleksihakli]
+
+
+6.0.0 (2023-05-17)
+------------------
+
+Version 6 is a breaking release. Please see the documentation for upgrade instructions.
+
+- Deprecate Python 3.7 support.
+  [aleksihakli]
+- Deprecate ``is_admin_site`` API call with misleading naming.
+  [hirotasoshu]
+- Add ``AXES_LOCKOUT_PARAMETERS`` configuration flag that will supersede ``AXES_ONLY_USER_FAILURES``, ``AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP``, ``AXES_LOCK_OUT_BY_USER_OR_IP``, and ``AXES_USE_USER_AGENT`` configurations. Add deprecation warnings for old flags. See project documentation on RTD for update instructions.
+  [hirotasoshu]
+- Improve translations.
+  [hirotasoshu]
+- Use Django ``cache.incr`` API for atomic cached failure counting
+  [hirotasoshu, aleksihakli]
+- Make ``django-ipware`` an optional dependency. Install it with e.g. ``pip install django-axes[ipware]`` package and extras specifier. [aleksihakli]
+- Deprecate and rename old configuration flags. Old flags will be removed in or after version ``6.1``. [aleksihakli]
+   * ``AXES_PROXY_ORDER`` is now ``AXES_IPWARE_PROXY_ORDER``,
+   * ``AXES_PROXY_COUNT`` is now ``AXES_IPWARE_PROXY_COUNT``,
+   * ``AXES_PROXY_TRUSTED_IPS`` is now ``AXES_IPWARE_PROXY_TRUSTED_IPS``, and
+   * ``AXES_META_PRECEDENCE_ORDER`` is now ``AXES_IPWARE_META_PRECEDENCE_ORDER``.
+- Set 429 as the default lockout response code. [hirotasoshu]
+
+
+5.41.1 (2023-04-16)
+-------------------
+
+- Fix sensitive parameter logging for database handler. [stereodamage]
+
+5.41.0 (2023-04-02)
+-------------------
+
+- Fix tests. [hirotasoshu]
+- Add ``AXES_CLIENT_CALLABLE`` setting. [hirotasoshu]
+- Update Python, Django, and package versions. [hramezani]
+
+
+5.40.1 (2022-11-24)
+-------------------
+
+- Fix bug in user agent request blocking. [PetrDlouhy]
+
+
+5.40.0 (2022-11-19)
+-------------------
+
+- Update packages and linters for new version support.
+  [hramezani]
+- Update documentation links.
+  [Arhell]
+- Use importlib instead of setuptools for Python 3.8+.
+  [jedie]
+- Python 3.11 support.
+  [joshuadavidthomas]
+- Documentation improvements.
+  [nsht]
+- Documentation improvements.
+  [timgates42]
+
+
 5.39.0 (2022-08-18)
 -------------------
 
diff --git a/docs/9_development.rst b/CONTRIBUTING.rst
similarity index 59%
rename from docs/9_development.rst
rename to CONTRIBUTING.rst
index 560d58a..5744708 100644
--- a/docs/9_development.rst
+++ b/CONTRIBUTING.rst
@@ -1,4 +1,30 @@
-.. _development:
+.. image:: https://jazzband.co/static/img/jazzband.svg
+   :target: https://jazzband.co/
+   :alt: Jazzband
+
+This is a `Jazzband <https://jazzband.co>`_ project. By contributing you agree to abide by the `Contributor Code of Conduct <https://jazzband.co/about/conduct>`_ and follow the `guidelines <https://jazzband.co/about/guidelines>`_.
+
+
+Contributions
+=============
+
+All contributions are welcome!
+
+It is best to separate proposed changes and PRs into small, distinct patches
+by type so that they can be merged faster into upstream and released quicker.
+
+One way to organize contributions would be to separate PRs for e.g.
+
+* bugfixes,
+* new features,
+* code and design improvements,
+* documentation improvements, or
+* tooling and CI improvements.
+
+Merging contributions requires passing the checks configured
+with the CI. This includes running tests and linters successfully
+on the currently officially supported Python and Django versions.
+
 
 Development
 ===========
diff --git a/README.rst b/README.rst
index 25f63a8..49d94b3 100644
--- a/README.rst
+++ b/README.rst
@@ -77,30 +77,7 @@ If you have questions or have trouble using the app please file a bug report at:
 https://github.com/jazzband/django-axes/issues
 
 
-Contributions
--------------
-
-All contributions are welcome!
-
-It is best to separate proposed changes and PRs into small, distinct patches
-by type so that they can be merged faster into upstream and released quicker.
-
-One way to organize contributions would be to separate PRs for e.g.
-
-* bugfixes,
-* new features,
-* code and design improvements,
-* documentation improvements, or
-* tooling and CI improvements.
-
-Merging contributions requires passing the checks configured
-with the CI. This includes running tests and linters successfully
-on the currently officially supported Python and Django versions.
-
-The test automation is run automatically with GitHub Actions, but you can
-run it locally with the ``tox`` command before pushing commits.
+Contributing
+------------
 
-Please note that this is a `Jazzband <https://jazzband.co>`_ project.
-By contributing you agree to abide by the
-`Contributor Code of Conduct <https://jazzband.co/about/conduct>`_
-and follow the `guidelines <https://jazzband.co/about/guidelines>`_.
+See `CONTRIBUTING <CONTRIBUTING.rst>`__.
diff --git a/axes/__init__.py b/axes/__init__.py
index 37645c8..e3011df 100644
--- a/axes/__init__.py
+++ b/axes/__init__.py
@@ -1,4 +1,8 @@
-from pkg_resources import get_distribution
+try:
+    from importlib.metadata import version  # New in Python 3.8
+except ImportError:
+    from pkg_resources import get_distribution  # from setuptools, deprecated
 
-
-__version__ = get_distribution("django-axes").version
+    __version__ = get_distribution("django-axes").version
+else:
+    __version__ = version("django-axes")
diff --git a/axes/apps.py b/axes/apps.py
index 5ef2612..9be1bf2 100644
--- a/axes/apps.py
+++ b/axes/apps.py
@@ -1,7 +1,10 @@
+# pylint: disable=import-outside-toplevel, unused-import
+
 from logging import getLogger
 
 from django import apps
-from pkg_resources import get_distribution
+
+from axes import __version__
 
 log = getLogger(__name__)
 
@@ -25,22 +28,29 @@ class AppConfig(apps.AppConfig):
         cls.initialized = True
 
         # Only import settings, checks, and signals one time after Django has been initialized
-        from axes.conf import settings  # noqa
-        from axes import checks, signals  # noqa
+        from axes.conf import settings
+        from axes import checks, signals
 
         # Skip startup log messages if Axes is not set to verbose
         if settings.AXES_VERBOSE:
-            if settings.AXES_ONLY_USER_FAILURES:
-                mode = "blocking by username only"
-            elif settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP:
-                mode = "blocking by combination of username and IP"
-            elif settings.AXES_LOCK_OUT_BY_USER_OR_IP:
-                mode = "blocking by username or IP"
+            if callable(settings.AXES_LOCKOUT_PARAMETERS) or isinstance(
+                settings.AXES_LOCKOUT_PARAMETERS, str
+            ):
+                mode = "blocking by parameters that are calculated in a custom callable"
+
             else:
-                mode = "blocking by IP only"
+                mode = "blocking by " + " or ".join(
+                    [
+                        param
+                        if isinstance(param, str)
+                        else "combination of " + " and ".join(param)
+                        for param in settings.AXES_LOCKOUT_PARAMETERS
+                    ]
+                )
+
             log.info(
                 "AXES: BEGIN version %s, %s",
-                get_distribution("django-axes").version,
+                __version__,
                 mode,
             )
 
diff --git a/axes/attempts.py b/axes/attempts.py
index fd6d552..80055c1 100644
--- a/axes/attempts.py
+++ b/axes/attempts.py
@@ -38,7 +38,7 @@ def filter_user_attempts(
     username = get_client_username(request, credentials)
 
     filter_kwargs_list = get_client_parameters(
-        username, request.axes_ip_address, request.axes_user_agent
+        username, request.axes_ip_address, request.axes_user_agent, request, credentials
     )
     attempts_list = [
         AccessAttempt.objects.filter(**filter_kwargs)
diff --git a/axes/checks.py b/axes/checks.py
index 711bcf8..65fb79f 100644
--- a/axes/checks.py
+++ b/axes/checks.py
@@ -122,6 +122,19 @@ def axes_deprecation_check(app_configs, **kwargs):  # pylint: disable=unused-arg
     deprecated_settings = [
         "AXES_DISABLE_SUCCESS_ACCESS_LOG",
         "AXES_LOGGER",
+        # AXES_PROXY_ and AXES_META_ parameters were updated to more explicit
+        # AXES_IPWARE_PROXY_ and AXES_IPWARE_META_ prefixes in version 6.x
+        "AXES_PROXY_ORDER",
+        "AXES_PROXY_COUNT",
+        "AXES_PROXY_TRUSTED_IPS",
+        "AXES_META_PRECEDENCE_ORDER",
+        # AXES_ONLY_USER_FAILURES, AXES_USE_USER_AGENT and
+        # AXES_LOCK_OUT parameters were replaced with AXES_LOCKOUT_PARAMETERS
+        # in version 6.x
+        "AXES_ONLY_USER_FAILURES",
+        "AXES_LOCK_OUT_BY_USER_OR_IP",
+        "AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP",
+        "AXES_USE_USER_AGENT",
     ]
 
     for deprecated_setting in deprecated_settings:
diff --git a/axes/conf.py b/axes/conf.py
index 745024f..daf1c70 100644
--- a/axes/conf.py
+++ b/axes/conf.py
@@ -1,7 +1,6 @@
 from django.conf import settings
 from django.utils.translation import gettext_lazy as _
 
-
 # disable plugin when set to False
 settings.AXES_ENABLED = getattr(settings, "AXES_ENABLED", True)
 
@@ -11,18 +10,30 @@ settings.AXES_FAILURE_LIMIT = getattr(settings, "AXES_FAILURE_LIMIT", 3)
 # see if the user has set axes to lock out logins after failure limit
 settings.AXES_LOCK_OUT_AT_FAILURE = getattr(settings, "AXES_LOCK_OUT_AT_FAILURE", True)
 
-# lock out with the combination of username and IP address
-settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP = getattr(
-    settings, "AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP", False
-)
-
-# lock out with the username or IP address
-settings.AXES_LOCK_OUT_BY_USER_OR_IP = getattr(
-    settings, "AXES_LOCK_OUT_BY_USER_OR_IP", False
-)
-
-# lock out with username and never the IP or user agent
-settings.AXES_ONLY_USER_FAILURES = getattr(settings, "AXES_ONLY_USER_FAILURES", False)
+# lockout parameters
+# default value will be ["ip_address"] after removing AXES_LOCK_OUT params support
+settings.AXES_LOCKOUT_PARAMETERS = getattr(settings, "AXES_LOCKOUT_PARAMETERS", None)
+
+# TODO: remove it in future versions
+if settings.AXES_LOCKOUT_PARAMETERS is None:
+    if getattr(settings, "AXES_ONLY_USER_FAILURES", False):
+        settings.AXES_LOCKOUT_PARAMETERS = ["username"]
+    else:
+        if getattr(settings, "AXES_LOCK_OUT_BY_USER_OR_IP", False):
+            settings.AXES_LOCKOUT_PARAMETERS = ["username", "ip_address"]
+        elif getattr(settings, "AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP", False):
+            settings.AXES_LOCKOUT_PARAMETERS = [["username", "ip_address"]]
+        else:
+            settings.AXES_LOCKOUT_PARAMETERS = ["ip_address"]
+
+    if getattr(settings, "AXES_USE_USER_AGENT", False):
+        if isinstance(settings.AXES_LOCKOUT_PARAMETERS[0], str):
+            settings.AXES_LOCKOUT_PARAMETERS[0] = [
+                settings.AXES_LOCKOUT_PARAMETERS[0],
+                "user_agent",
+            ]
+        else:
+            settings.AXES_LOCKOUT_PARAMETERS[0].append("user_agent")
 
 # lock out just for admin site
 settings.AXES_ONLY_ADMIN_SITE = getattr(settings, "AXES_ONLY_ADMIN_SITE", False)
@@ -30,9 +41,6 @@ settings.AXES_ONLY_ADMIN_SITE = getattr(settings, "AXES_ONLY_ADMIN_SITE", False)
 # show Axes logs in admin
 settings.AXES_ENABLE_ADMIN = getattr(settings, "AXES_ENABLE_ADMIN", True)
 
-# lock out with the user agent, has no effect when ONLY_USER_FAILURES is set
-settings.AXES_USE_USER_AGENT = getattr(settings, "AXES_USE_USER_AGENT", False)
-
 # use a specific username field to retrieve from login POST data
 settings.AXES_USERNAME_FORM_FIELD = getattr(
     settings, "AXES_USERNAME_FORM_FIELD", "username"
@@ -52,6 +60,9 @@ settings.AXES_WHITELIST_CALLABLE = getattr(settings, "AXES_WHITELIST_CALLABLE",
 # return custom lockout response if configured
 settings.AXES_LOCKOUT_CALLABLE = getattr(settings, "AXES_LOCKOUT_CALLABLE", None)
 
+# use a provided callable to get client ip address
+settings.AXES_CLIENT_IP_CALLABLE = getattr(settings, "AXES_CLIENT_IP_CALLABLE", None)
+
 # reset the number of failed attempts after one successful attempt
 settings.AXES_RESET_ON_SUCCESS = getattr(settings, "AXES_RESET_ON_SUCCESS", False)
 
@@ -106,24 +117,6 @@ settings.AXES_PERMALOCK_MESSAGE = getattr(
     ),
 )
 
-# if your deployment is using reverse proxies, set this value to 'left-most' or 'right-most' per your configuration
-settings.AXES_PROXY_ORDER = getattr(settings, "AXES_PROXY_ORDER", "left-most")
-
-# if your deployment is using reverse proxies, set this value to the number of proxies in front of Django
-settings.AXES_PROXY_COUNT = getattr(settings, "AXES_PROXY_COUNT", None)
-
-# if your deployment is using reverse proxies, set to your trusted proxy IP addresses prefixes if needed
-settings.AXES_PROXY_TRUSTED_IPS = getattr(settings, "AXES_PROXY_TRUSTED_IPS", None)
-
-# set to the names of request.META attributes that should be checked for the IP address of the client
-# if your deployment is using reverse proxies, ensure that the header attributes are securely set by the proxy
-# ensure that the client can not spoof the headers by setting them and sending them through the proxy
-settings.AXES_META_PRECEDENCE_ORDER = getattr(
-    settings,
-    "AXES_META_PRECEDENCE_ORDER",
-    getattr(settings, "IPWARE_META_PRECEDENCE_ORDER", ("REMOTE_ADDR",)),
-)
-
 # set CORS allowed origins when calling authentication over ajax
 settings.AXES_ALLOWED_CORS_ORIGINS = getattr(settings, "AXES_ALLOWED_CORS_ORIGINS", "*")
 
@@ -139,9 +132,50 @@ settings.AXES_SENSITIVE_PARAMETERS = getattr(
 settings.AXES_CLIENT_STR_CALLABLE = getattr(settings, "AXES_CLIENT_STR_CALLABLE", None)
 
 # set the HTTP response code given by too many requests
-settings.AXES_HTTP_RESPONSE_CODE = getattr(settings, "AXES_HTTP_RESPONSE_CODE", 403)
+settings.AXES_HTTP_RESPONSE_CODE = getattr(settings, "AXES_HTTP_RESPONSE_CODE", 429)
 
 # 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
 )
+
+
+###
+# django-ipware settings for client IP address calculation and proxy detection
+# there are old AXES_PROXY_ and AXES_META_ legacy keys present for backwards compatibility
+# see https://github.com/un33k/django-ipware for further details
+###
+
+# if your deployment is using reverse proxies, set this value to 'left-most' or 'right-most' per your configuration
+settings.AXES_IPWARE_PROXY_ORDER = getattr(
+    settings,
+    "AXES_IPWARE_PROXY_ORDER",
+    getattr(settings, "AXES_PROXY_ORDER", "left-most"),
+)
+
+# if your deployment is using reverse proxies, set this value to the number of proxies in front of Django
+settings.AXES_IPWARE_PROXY_COUNT = getattr(
+    settings,
+    "AXES_IPWARE_PROXY_COUNT",
+    getattr(settings, "AXES_PROXY_COUNT", None),
+)
+
+# if your deployment is using reverse proxies, set to your trusted proxy IP addresses prefixes if needed
+settings.AXES_IPWARE_PROXY_TRUSTED_IPS = getattr(
+    settings,
+    "AXES_IPWARE_PROXY_TRUSTED_IPS",
+    getattr(settings, "AXES_PROXY_TRUSTED_IPS", None),
+)
+
+# set to the names of request.META attributes that should be checked for the IP address of the client
+# if your deployment is using reverse proxies, ensure that the header attributes are securely set by the proxy
+# ensure that the client can not spoof the headers by setting them and sending them through the proxy
+settings.AXES_IPWARE_META_PRECEDENCE_ORDER = getattr(
+    settings,
+    "AXES_IPWARE_META_PRECEDENCE_ORDER",
+    getattr(
+        settings,
+        "AXES_META_PRECEDENCE_ORDER",
+        getattr(settings, "IPWARE_META_PRECEDENCE_ORDER", ("REMOTE_ADDR",)),
+    ),
+)
diff --git a/axes/handlers/base.py b/axes/handlers/base.py
index 8c192ed..62d70f3 100644
--- a/axes/handlers/base.py
+++ b/axes/handlers/base.py
@@ -1,6 +1,7 @@
 import re
 from abc import ABC, abstractmethod
 from typing import Optional
+from warnings import warn
 
 from django.urls import reverse
 from django.urls.exceptions import NoReverseMatch
@@ -81,7 +82,7 @@ class AxesBaseHandler:  # pylint: disable=unused-argument
         and inspiration on some common checks and access restrictions before writing your own implementation.
         """
 
-        if self.is_admin_site(request):
+        if settings.AXES_ONLY_ADMIN_SITE and not self.is_admin_request(request):
             return True
 
         if self.is_blacklisted(request, credentials):
@@ -134,10 +135,41 @@ class AxesBaseHandler:  # pylint: disable=unused-argument
 
         return False
 
-    def is_admin_site(self, request) -> bool:
+    def get_admin_url(self) -> Optional[str]:
+        """
+        Returns admin url if exists, otherwise returns None
+        """
+        try:
+            return reverse("admin:index")
+        except NoReverseMatch:
+            return None
+
+    def is_admin_request(self, request) -> bool:
+        """
+        Checks that request located under admin site
         """
-        Checks if the request is for admin site.
+        if hasattr(request, "path"):
+            admin_url = self.get_admin_url()
+            return (
+                admin_url is not None
+                and re.match(f"^{admin_url}", request.path) is not None
+            )
+
+        return False
+
+    def is_admin_site(self, request) -> bool:
         """
+        Checks if the request is NOT for admin site
+        if `settings.AXES_ONLY_ADMIN_SITE` is True.
+        """
+        warn(
+            (
+                "This method is deprecated and will be removed in future versions. "
+                "If you looking for method that checks if `request.path` located under "
+                "admin site, use `is_admin_request` instead."
+            ),
+            DeprecationWarning,
+        )
         if settings.AXES_ONLY_ADMIN_SITE and hasattr(request, "path"):
             try:
                 admin_url = reverse("admin:index")
diff --git a/axes/handlers/cache.py b/axes/handlers/cache.py
index 423c592..a797f39 100644
--- a/axes/handlers/cache.py
+++ b/axes/handlers/cache.py
@@ -6,11 +6,12 @@ from axes.handlers.base import AxesBaseHandler, AbstractAxesHandler
 from axes.helpers import (
     get_cache,
     get_cache_timeout,
-    get_client_cache_key,
+    get_client_cache_keys,
     get_client_str,
     get_client_username,
     get_credentials,
     get_failure_limit,
+    get_lockout_parameters,
 )
 from axes.models import AccessAttempt
 from axes.signals import user_locked_out
@@ -29,8 +30,8 @@ class AxesCacheHandler(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:
         cache_keys: list = []
@@ -44,7 +45,7 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
             )
 
         cache_keys.extend(
-            get_client_cache_key(
+            get_client_cache_keys(
                 AccessAttempt(username=username, ip_address=ip_address)
             )
         )
@@ -58,7 +59,7 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
         return count
 
     def get_failures(self, request, credentials: Optional[dict] = None) -> int:
-        cache_keys = get_client_cache_key(request, credentials)
+        cache_keys = get_client_cache_keys(request, credentials)
         failure_count = max(
             self.cache.get(cache_key, default=0) for cache_key in cache_keys
         )
@@ -78,9 +79,10 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
             return
 
         username = get_client_username(request, credentials)
-        if settings.AXES_ONLY_USER_FAILURES and username is None:
+        lockout_parameters = get_lockout_parameters(request, credentials)
+        if lockout_parameters == ["username"] and username is None:
             log.warning(
-                "AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, new record will NOT be created."
+                "AXES: Username is None and username is the only one lockout parameter, new record will NOT be created."
             )
             return
 
@@ -110,7 +112,18 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
             log.info("AXES: Login failed from whitelisted client %s.", client_str)
             return
 
-        failures_since_start = 1 + self.get_failures(request, credentials)
+        cache_keys = get_client_cache_keys(request, credentials)
+        cache_timeout = get_cache_timeout()
+        failures = []
+        for cache_key in cache_keys:
+            added = self.cache.add(key=cache_key, value=1, timeout=cache_timeout)
+            if added:
+                failures.append(1)
+            else:
+                failures.append(self.cache.incr(key=cache_key, delta=1))
+                self.cache.touch(key=cache_key, timeout=cache_timeout)
+
+        failures_since_start = max(failures)
         request.axes_failures_since_start = failures_since_start
 
         if failures_since_start > 1:
@@ -126,11 +139,6 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
                 client_str,
             )
 
-        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, get_cache_timeout())
-
         if (
             settings.AXES_LOCK_OUT_AT_FAILURE
             and failures_since_start >= get_failure_limit(request, credentials)
@@ -166,7 +174,7 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
         log.info("AXES: Successful login by %s.", client_str)
 
         if settings.AXES_RESET_ON_SUCCESS:
-            cache_keys = get_client_cache_key(request, credentials)
+            cache_keys = get_client_cache_keys(request, credentials)
             for cache_key in cache_keys:
                 failures_since_start = self.cache.get(cache_key, default=0)
                 self.cache.delete(cache_key)
diff --git a/axes/handlers/database.py b/axes/handlers/database.py
index 4e4dc67..035cc60 100644
--- a/axes/handlers/database.py
+++ b/axes/handlers/database.py
@@ -18,6 +18,7 @@ from axes.helpers import (
     get_client_username,
     get_credentials,
     get_failure_limit,
+    get_lockout_parameters,
     get_query_str,
 )
 from axes.models import AccessLog, AccessAttempt, AccessFailureLog
@@ -164,9 +165,10 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
             return
 
         # 2. database query: Get or create access record with the new failure data
-        if settings.AXES_ONLY_USER_FAILURES and username is None:
+        lockout_parameters = get_lockout_parameters(request, credentials)
+        if lockout_parameters == ["username"] and username is None:
             log.warning(
-                "AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, new record will NOT be created."
+                "AXES: Username is None and username is the only one lockout parameter, new record will NOT be created."
             )
         else:
             with transaction.atomic():
diff --git a/axes/handlers/test.py b/axes/handlers/test.py
index bea66b3..5213e2c 100644
--- a/axes/handlers/test.py
+++ b/axes/handlers/test.py
@@ -2,7 +2,7 @@ from axes.handlers.base import AxesHandler
 from typing import Optional
 
 
-class AxesTestHandler(AxesHandler):  # pylint: disable=unused-argument
+class AxesTestHandler(AxesHandler):
     """
     Signal handler implementation that does nothing, ideal for a test suite.
     """
diff --git a/axes/helpers.py b/axes/helpers.py
index 879a784..c951281 100644
--- a/axes/helpers.py
+++ b/axes/helpers.py
@@ -2,13 +2,12 @@ from datetime import timedelta
 from hashlib import sha256
 from logging import getLogger
 from string import Template
-from typing import Callable, Optional, Type, Union
+from typing import Callable, Optional, Type, Union, List
 from urllib.parse import urlencode
 
-import ipware.ip
-from django.core.cache import caches, BaseCache
+from django.core.cache import BaseCache, caches
 from django.http import HttpRequest, HttpResponse, JsonResponse, QueryDict
-from django.shortcuts import render, redirect
+from django.shortcuts import redirect, render
 from django.utils.module_loading import import_string
 
 from axes.conf import settings
@@ -16,6 +15,13 @@ from axes.models import AccessBase
 
 log = getLogger(__name__)
 
+try:
+    import ipware.ip
+
+    IPWARE_INSTALLED = True
+except ImportError:
+    IPWARE_INSTALLED = False
+
 
 def get_cache() -> BaseCache:
     """
@@ -148,23 +154,55 @@ def get_client_username(
     return request_data.get(settings.AXES_USERNAME_FORM_FIELD, None)
 
 
-def get_client_ip_address(request: HttpRequest) -> str:
+def get_client_ip_address(
+    request: HttpRequest,
+    use_ipware: Optional[bool] = None,
+) -> Optional[str]:
     """
     Get client IP address as configured by the user.
 
-    The django-ipware package is used for address resolution
-    and parameters can be configured in the Axes package.
+    The order of preference for address resolution is as follows:
+
+    1. If configured, use ``AXES_CLIENT_IP_CALLABLE``, and supply ``request`` as argument
+    2. If available, use django-ipware package (parameters can be configured in the Axes package)
+    3. Use ``request.META.get('REMOTE_ADDR', None)`` as a fallback
+
+    :param request: incoming Django ``HttpRequest`` or similar object from authentication backend or other source
     """
 
-    client_ip_address, _ = ipware.ip.get_client_ip(
-        request,
-        proxy_order=settings.AXES_PROXY_ORDER,
-        proxy_count=settings.AXES_PROXY_COUNT,
-        proxy_trusted_ips=settings.AXES_PROXY_TRUSTED_IPS,
-        request_header_order=settings.AXES_META_PRECEDENCE_ORDER,
-    )
+    if settings.AXES_CLIENT_IP_CALLABLE:
+        log.debug("Using settings.AXES_CLIENT_IP_CALLABLE to get client IP address")
+
+        if callable(settings.AXES_CLIENT_IP_CALLABLE):
+            return settings.AXES_CLIENT_IP_CALLABLE(  # pylint: disable=not-callable
+                request
+            )
+        if isinstance(settings.AXES_CLIENT_IP_CALLABLE, str):
+            return import_string(settings.AXES_CLIENT_IP_CALLABLE)(request)
+        raise TypeError(
+            "settings.AXES_CLIENT_IP_CALLABLE needs to be a string, callable, or None."
+        )
 
-    return client_ip_address
+    # Resolve using django-ipware from a configuration flag that can be set to False to explicitly disable
+    # this is added to both enable or disable the branch when ipware is installed in the test environment
+    if use_ipware is None:
+        use_ipware = IPWARE_INSTALLED
+    if use_ipware:
+        log.debug("Using django-ipware to get client IP address")
+
+        client_ip_address, _ = ipware.ip.get_client_ip(
+            request,
+            proxy_order=settings.AXES_IPWARE_PROXY_ORDER,
+            proxy_count=settings.AXES_IPWARE_PROXY_COUNT,
+            proxy_trusted_ips=settings.AXES_IPWARE_PROXY_TRUSTED_IPS,
+            request_header_order=settings.AXES_IPWARE_META_PRECEDENCE_ORDER,
+        )
+        return client_ip_address
+
+    log.debug(
+        "Using request.META.get('REMOTE_ADDR', None) fallback method to get client IP address"
+    )
+    return request.META.get("REMOTE_ADDR", None)
 
 
 def get_client_user_agent(request: HttpRequest) -> str:
@@ -179,7 +217,33 @@ def get_client_http_accept(request: HttpRequest) -> str:
     return request.META.get("HTTP_ACCEPT", "<unknown>")[:1025]
 
 
-def get_client_parameters(username: str, ip_address: str, user_agent: str) -> list:
+def get_lockout_parameters(
+    request_or_attempt: Union[HttpRequest, AccessBase],
+    credentials: Optional[dict] = None,
+) -> List[Union[str, List[str]]]:
+    if callable(settings.AXES_LOCKOUT_PARAMETERS):
+        return settings.AXES_LOCKOUT_PARAMETERS(request_or_attempt, credentials)
+
+    if isinstance(settings.AXES_LOCKOUT_PARAMETERS, str):
+        return import_string(settings.AXES_LOCKOUT_PARAMETERS)(
+            request_or_attempt, credentials
+        )
+
+    if isinstance(settings.AXES_LOCKOUT_PARAMETERS, list):
+        return settings.AXES_LOCKOUT_PARAMETERS
+
+    raise TypeError(
+        "settings.AXES_LOCKOUT_PARAMETERS needs to be a callable or iterable"
+    )
+
+
+def get_client_parameters(
+    username: str,
+    ip_address: str,
+    user_agent: str,
+    request_or_attempt: Union[HttpRequest, AccessBase],
+    credentials: Optional[dict] = None,
+) -> List[dict]:
     """
     Get query parameters for filtering AccessAttempt queryset.
 
@@ -188,29 +252,39 @@ def get_client_parameters(username: str, ip_address: str, user_agent: str) -> li
 
     Returns list of dict, every item of list are separate parameters
     """
+    lockout_parameters = get_lockout_parameters(request_or_attempt, credentials)
 
-    if settings.AXES_ONLY_USER_FAILURES:
-        # 1. Only individual usernames can be tracked with parametrization
-        filter_query = [{"username": username}]
-    else:
-        if settings.AXES_LOCK_OUT_BY_USER_OR_IP:
-            # One of `username` or `IP address` is used
-            filter_query = [{"username": username}, {"ip_address": ip_address}]
-        elif settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP:
-            # 2. A combination of username and IP address can be used as well
-            filter_query = [{"username": username, "ip_address": ip_address}]
-        else:
-            # 3. Default case is to track the IP address only, which is the most secure option
-            filter_query = [{"ip_address": ip_address}]
+    parameters_dict = {
+        "username": username,
+        "ip_address": ip_address,
+        "user_agent": user_agent,
+    }
 
-        if settings.AXES_USE_USER_AGENT:
-            # 4. The HTTP User-Agent can be used to track e.g. one browser
-            filter_query.append({"user_agent": user_agent})
+    filter_kwargs = []
+
+    for parameter in lockout_parameters:
+        try:
+            if isinstance(parameter, str):
+                filter_kwarg = {parameter: parameters_dict[parameter]}
+            else:
+                filter_kwarg = {
+                    combined_parameter: parameters_dict[combined_parameter]
+                    for combined_parameter in parameter
+                }
+            filter_kwargs.append(filter_kwarg)
+
+        except KeyError as e:
+            error_msg = (
+                f"{e} lockout parameter is not allowed. "
+                f"Allowed parameters: {', '.join(parameters_dict.keys())}"
+            )
+            log.exception(error_msg)
+            raise ValueError(error_msg) from e
 
-    return filter_query
+    return filter_kwargs
 
 
-def make_cache_key_list(filter_kwargs_list):
+def make_cache_key_list(filter_kwargs_list: List[dict]) -> List[str]:
     cache_keys = []
     for filter_kwargs in filter_kwargs_list:
         cache_key_components = "".join(
@@ -221,10 +295,10 @@ def make_cache_key_list(filter_kwargs_list):
     return cache_keys
 
 
-def get_client_cache_key(
+def get_client_cache_keys(
     request_or_attempt: Union[HttpRequest, AccessBase],
     credentials: Optional[dict] = None,
-) -> str:
+) -> List[str]:
     """
     Build cache key name from request or AccessAttempt object.
 
@@ -242,7 +316,9 @@ def get_client_cache_key(
         ip_address = get_client_ip_address(request_or_attempt)
         user_agent = get_client_user_agent(request_or_attempt)
 
-    filter_kwargs_list = get_client_parameters(username, ip_address, user_agent)
+    filter_kwargs_list = get_client_parameters(
+        username, ip_address, user_agent, request_or_attempt, credentials
+    )
 
     return make_cache_key_list(filter_kwargs_list)
 
@@ -285,11 +361,11 @@ def get_client_str(
         client_dict["user_agent"] = user_agent
     else:
         # Other modes initialize the attributes that are used for the actual lockouts
-        client_list = get_client_parameters(username, ip_address, user_agent)
+        client_list = get_client_parameters(username, ip_address, user_agent, request)
         client_dict = {}
         for client in client_list:
             client_dict.update(client)
-
+    client_dict = cleanse_parameters(client_dict.copy())
     # Path info is always included as last component in the client string for traceability purposes
     if path_info and isinstance(path_info, (tuple, list)):
         path_info = path_info[0]
diff --git a/axes/locale/ru/LC_MESSAGES/django.mo b/axes/locale/ru/LC_MESSAGES/django.mo
index 36806fb..6341f31 100644
Binary files a/axes/locale/ru/LC_MESSAGES/django.mo and b/axes/locale/ru/LC_MESSAGES/django.mo differ
diff --git a/axes/locale/ru/LC_MESSAGES/django.po b/axes/locale/ru/LC_MESSAGES/django.po
index b5089d4..bff6041 100644
--- a/axes/locale/ru/LC_MESSAGES/django.po
+++ b/axes/locale/ru/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2019-01-11 12:20+0300\n"
+"POT-Creation-Date: 2023-05-13 12:36+0500\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -18,80 +18,92 @@ msgstr ""
 "Content-Transfer-Encoding: 8bit\n"
 "Plural-Forms: nplurals=2; plural=(n != 1);\n"
 
-#: axes/admin.py:38
+#: axes/admin.py:27
 msgid "Form Data"
 msgstr "Данные формы"
 
-#: axes/admin.py:41 axes/admin.py:95
+#: axes/admin.py:28 axes/admin.py:65 axes/admin.py:100
 msgid "Meta Data"
 msgstr "Метаданные"
 
-#: axes/conf.py:58
+#: axes/conf.py:99
 msgid "Account locked: too many login attempts. Please try again later."
 msgstr ""
-"Учетная запись заблокирована: слишком много попыток входа. "
-"Повторите попытку позже."
+"Учетная запись заблокирована: слишком много попыток входа. Повторите попытку "
+"позже."
 
-#: axes/conf.py:61
+#: axes/conf.py:107
 msgid ""
 "Account locked: too many login attempts. Contact an admin to unlock your "
 "account."
 msgstr ""
-"Учетная запись заблокирована: слишком много попыток входа. "
-"Обратитесь к администратору для разблокирования учетной записи."
+"Учетная запись заблокирована: слишком много попыток входа. Свяжитесь с "
+"администратором, чтобы разблокировать учетную запись."
 
-#: axes/models.py:9
+#: axes/models.py:6
 msgid "User Agent"
-msgstr "Браузер пользователя"
+msgstr "User Agent"
 
-#: axes/models.py:15
+#: axes/models.py:8
 msgid "IP Address"
-msgstr "Адрес IP"
+msgstr "IP Адрес"
 
-#: axes/models.py:21
+#: axes/models.py:10
 msgid "Username"
-msgstr "Пользователь"
+msgstr "Имя пользователя"
 
-#: axes/models.py:35
+#: axes/models.py:12
 msgid "HTTP Accept"
-msgstr "Запрос HTTP"
+msgstr "HTTP Accept"
 
-#: axes/models.py:40
+#: axes/models.py:14
 msgid "Path"
 msgstr "Путь"
 
-#: axes/models.py:45
+#: axes/models.py:16
 msgid "Attempt Time"
-msgstr "Время входа"
+msgstr "Время попытки входа"
+
+#: axes/models.py:26
+msgid "Access lock out"
+msgstr "Доступ запрещен"
 
-#: axes/models.py:57
+#: axes/models.py:34
+msgid "access failure"
+msgstr "Ошибка доступа"
+
+#: axes/models.py:35
+msgid "access failures"
+msgstr "Ошибки доступа"
+
+#: axes/models.py:39
 msgid "GET Data"
 msgstr "Данные GET-запроса"
 
-#: axes/models.py:61
+#: axes/models.py:41
 msgid "POST Data"
 msgstr "Данные POST-запроса"
 
-#: axes/models.py:65
+#: axes/models.py:43
 msgid "Failed Logins"
 msgstr "Ошибочные попытки"
 
-#: axes/models.py:76
+#: axes/models.py:49
 msgid "access attempt"
 msgstr "Запись о попытке доступа"
 
-#: axes/models.py:77
+#: axes/models.py:50
 msgid "access attempts"
 msgstr "Попытки доступа"
 
-#: axes/models.py:81
+#: axes/models.py:55
 msgid "Logout Time"
 msgstr "Время выхода"
 
-#: axes/models.py:90
+#: axes/models.py:61
 msgid "access log"
 msgstr "Запись о доступе"
 
-#: axes/models.py:91
+#: axes/models.py:62
 msgid "access logs"
 msgstr "Логи доступа"
diff --git a/axes/migrations/0001_initial.py b/axes/migrations/0001_initial.py
index 1535183..d361b72 100644
--- a/axes/migrations/0001_initial.py
+++ b/axes/migrations/0001_initial.py
@@ -2,7 +2,6 @@ from django.db import migrations, models
 
 
 class Migration(migrations.Migration):
-
     dependencies = []
 
     operations = [
diff --git a/axes/migrations/0002_auto_20151217_2044.py b/axes/migrations/0002_auto_20151217_2044.py
index 62bc371..0ed902a 100644
--- a/axes/migrations/0002_auto_20151217_2044.py
+++ b/axes/migrations/0002_auto_20151217_2044.py
@@ -2,7 +2,6 @@ from django.db import migrations, models
 
 
 class Migration(migrations.Migration):
-
     dependencies = [("axes", "0001_initial")]
 
     operations = [
diff --git a/axes/migrations/0003_auto_20160322_0929.py b/axes/migrations/0003_auto_20160322_0929.py
index 18e3941..37106ab 100644
--- a/axes/migrations/0003_auto_20160322_0929.py
+++ b/axes/migrations/0003_auto_20160322_0929.py
@@ -2,7 +2,6 @@ from django.db import models, migrations
 
 
 class Migration(migrations.Migration):
-
     dependencies = [("axes", "0002_auto_20151217_2044")]
 
     operations = [
diff --git a/axes/migrations/0004_auto_20181024_1538.py b/axes/migrations/0004_auto_20181024_1538.py
index fc88826..2d177d6 100644
--- a/axes/migrations/0004_auto_20181024_1538.py
+++ b/axes/migrations/0004_auto_20181024_1538.py
@@ -2,7 +2,6 @@ from django.db import migrations, models
 
 
 class Migration(migrations.Migration):
-
     dependencies = [("axes", "0003_auto_20160322_0929")]
 
     operations = [
diff --git a/axes/migrations/0005_remove_accessattempt_trusted.py b/axes/migrations/0005_remove_accessattempt_trusted.py
index c7711fb..3716f25 100644
--- a/axes/migrations/0005_remove_accessattempt_trusted.py
+++ b/axes/migrations/0005_remove_accessattempt_trusted.py
@@ -2,7 +2,6 @@ from django.db import migrations
 
 
 class Migration(migrations.Migration):
-
     dependencies = [("axes", "0004_auto_20181024_1538")]
 
     operations = [migrations.RemoveField(model_name="accessattempt", name="trusted")]
diff --git a/axes/migrations/0006_remove_accesslog_trusted.py b/axes/migrations/0006_remove_accesslog_trusted.py
index 347e0f0..014abb1 100644
--- a/axes/migrations/0006_remove_accesslog_trusted.py
+++ b/axes/migrations/0006_remove_accesslog_trusted.py
@@ -4,7 +4,6 @@ from django.db import migrations
 
 
 class Migration(migrations.Migration):
-
     dependencies = [("axes", "0005_remove_accessattempt_trusted")]
 
     operations = [migrations.RemoveField(model_name="accesslog", name="trusted")]
diff --git a/axes/migrations/0007_alter_accessattempt_unique_together.py b/axes/migrations/0007_alter_accessattempt_unique_together.py
index d84e3fb..508c4fd 100644
--- a/axes/migrations/0007_alter_accessattempt_unique_together.py
+++ b/axes/migrations/0007_alter_accessattempt_unique_together.py
@@ -6,25 +6,26 @@ from django.db.models import Count
 
 def deduplicate_attempts(apps, schema_editor):
     AccessAttempt = apps.get_model("axes", "AccessAttempt")
+    db_alias = schema_editor.connection.alias
     duplicated_attempts = (
-        AccessAttempt.objects.values("username", "user_agent", "ip_address")
+        AccessAttempt.objects.using(db_alias)
+        .values("username", "user_agent", "ip_address")
         .annotate(Count("id"))
         .order_by()
         .filter(id__count__gt=1)
     )
 
     for attempt in duplicated_attempts:
-        redundant_attempts = AccessAttempt.objects.filter(
+        redundant_attempts = AccessAttempt.objects.using(db_alias).filter(
             username=attempt["username"],
             user_agent=attempt["user_agent"],
             ip_address=attempt["ip_address"],
         )[1:]
         for redundant_attempt in redundant_attempts:
-            redundant_attempt.delete()
+            redundant_attempt.delete(using=db_alias)
 
 
 class Migration(migrations.Migration):
-
     dependencies = [
         ("axes", "0006_remove_accesslog_trusted"),
     ]
diff --git a/axes/migrations/0008_accessfailurelog.py b/axes/migrations/0008_accessfailurelog.py
index ac7c549..5d6d0e2 100644
--- a/axes/migrations/0008_accessfailurelog.py
+++ b/axes/migrations/0008_accessfailurelog.py
@@ -4,7 +4,6 @@ from django.db import migrations, models
 
 
 class Migration(migrations.Migration):
-
     dependencies = [
         ("axes", "0007_alter_accessattempt_unique_together"),
     ]
diff --git a/axes/utils.py b/axes/utils.py
index 55c82ef..c9983d5 100644
--- a/axes/utils.py
+++ b/axes/utils.py
@@ -10,9 +10,8 @@ from typing import Optional
 
 from django.http import HttpRequest
 
-from axes.conf import settings
 from axes.handlers.proxy import AxesProxyHandler
-from axes.helpers import get_client_ip_address
+from axes.helpers import get_client_ip_address, get_lockout_parameters
 
 log = getLogger(__name__)
 
@@ -37,23 +36,38 @@ def reset_request(request: HttpRequest) -> int:
 
     This utility method is meant to be used from the CLI or via Python API.
     """
+    lockout_paramaters = get_lockout_parameters(request)
 
     ip: Optional[str] = get_client_ip_address(request)
     username = request.GET.get("username", None)
 
-    ip_or_username = settings.AXES_LOCK_OUT_BY_USER_OR_IP
-    if settings.AXES_ONLY_USER_FAILURES:
+    ip_required = False
+    username_required = False
+    ip_and_username = False
+
+    for param in lockout_paramaters:
+        # hack: in works with all iterables, including strings
+        # so this checks works with separate parameters
+        # and with parameters combinations
+        if "username" in param and "ip_address" in param:
+            ip_and_username = True
+            ip_required = True
+            username_required = True
+            break
+        if "username" in param:
+            username_required = True
+        elif "ip_address" in param:
+            ip_required = True
+
+    ip_or_username = not ip_and_username and ip_required and username_required
+    if not ip_required:
         ip = None
-    elif not (
-        settings.AXES_LOCK_OUT_BY_USER_OR_IP
-        or settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP
-    ):
+    if not username_required:
         username = None
 
     if not ip and not username:
         return 0
         # We don't want to reset everything, if there is some wrong request parameter
 
-    # if settings.AXES_USE_USER_AGENT:
     # TODO: reset based on user_agent?
     return reset(ip, username, ip_or_username)
diff --git a/debian/changelog b/debian/changelog
index 2adfc85..85393b7 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,12 @@
+django-axes (6.0.1-1) UNRELEASED; urgency=low
+
+  * New upstream release.
+  * New upstream release.
+  * Drop patch 0001-Adopt-test-based-on-Django-security-release.patch, present
+    upstream.
+
+ -- Debian Janitor <janitor@jelmer.uk>  Fri, 02 Jun 2023 23:44:53 -0000
+
 django-axes (5.39.0-2) unstable; urgency=medium
 
   * Adopt test based on Django security release (Closes: #1031461)
diff --git a/debian/patches/0001-Adopt-test-based-on-Django-security-release.patch b/debian/patches/0001-Adopt-test-based-on-Django-security-release.patch
deleted file mode 100644
index 36b73ad..0000000
--- a/debian/patches/0001-Adopt-test-based-on-Django-security-release.patch
+++ /dev/null
@@ -1,24 +0,0 @@
-From: James Valleroy <jvalleroy@mailbox.org>
-Date: Sat, 18 Feb 2023 09:09:03 -0500
-Subject: Adopt test based on Django security release
-
-Apply patch from upstream PR to fix test.
-
-Origin: https://github.com/jazzband/django-axes/pull/1004
-Bug-Debian: https://bugs.debian.org/1031461
----
- tests/test_logging.py | 1 +
- 1 file changed, 1 insertion(+)
-
-diff --git a/tests/test_logging.py b/tests/test_logging.py
-index 35ae52e..ae2a7c6 100644
---- a/tests/test_logging.py
-+++ b/tests/test_logging.py
-@@ -69,6 +69,7 @@ class AccessLogTestCase(AxesTestCase):
- 
-         self.assertIsNotNone(AccessLog.objects.latest("id").logout_time)
- 
-+    @override_settings(DATA_UPLOAD_MAX_NUMBER_FIELDS=1500)
-     def test_log_data_truncated(self):
-         """
-         Test that get_query_str properly truncates data to the max_length (default 1024).
diff --git a/debian/patches/series b/debian/patches/series
index 6b5ac88..e69de29 100644
--- a/debian/patches/series
+++ b/debian/patches/series
@@ -1 +0,0 @@
-0001-Adopt-test-based-on-Django-security-release.patch
diff --git a/docs/1_requirements.rst b/docs/1_requirements.rst
index 11b31f8..d690cf4 100644
--- a/docs/1_requirements.rst
+++ b/docs/1_requirements.rst
@@ -3,11 +3,11 @@
 Requirements
 ============
 
-Axes requires a supported Django version and runs on Python versions 3.6 and above.
+Axes requires a supported Django version and runs on Python versions 3.8 and above.
 
 Refer to the project source code repository in
 `GitHub <https://github.com/jazzband/django-axes/>`_ and see the
-`Tox configuration <https://github.com/jazzband/django-axes/blob/master/tox.ini>`_ and
+`pyproject.toml file <https://github.com/jazzband/django-axes/blob/master/pyproject.toml>`_ and
 `Python package definition <https://github.com/jazzband/django-axes/blob/master/setup.py>`_
 to check if your Django and Python version are supported.
 
diff --git a/docs/2_installation.rst b/docs/2_installation.rst
index e001768..21dc0dc 100644
--- a/docs/2_installation.rst
+++ b/docs/2_installation.rst
@@ -5,7 +5,8 @@ Installation
 
 Axes is easy to install from the PyPI package::
 
-    $ pip install django-axes
+    $ pip install django-axes[ipware]  # use django-ipware for resolving client IP addresses OR
+    $ pip install django-axes          # implement and configure custom AXES_CLIENT_IP_CALLABLE
 
 After installing the package, the project settings need to be configured.
 
@@ -33,10 +34,10 @@ After installing the package, the project settings need to be configured.
         'django.contrib.auth.backends.ModelBackend',
     ]
 
-    For backwards compatibility, ``AxesBackend`` can be used in place of ``AxesStandaloneBackend``. 
+    For backwards compatibility, ``AxesBackend`` can be used in place of ``AxesStandaloneBackend``.
     The only difference is that ``AxesBackend`` also provides the permissions-checking functionality
     of Django's ``ModelBackend`` behind the scenes. We recommend using ``AxesStandaloneBackend``
-    if you have any custom logic to override Django's standard permissions checks. 
+    if you have any custom logic to override Django's standard permissions checks.
 
 **3.** Add ``axes.middleware.AxesMiddleware`` to your list of ``MIDDLEWARE``::
 
@@ -74,6 +75,76 @@ Many people have different configurations for their development and production e
 and running the application with misconfigured settings can prevent security features from working.
 
 
+Version 6 breaking changes and upgrading from django-axes version 5
+-------------------------------------------------------------------
+
+If you have not specialized ``django-axes`` configuration in any way
+you do not have to update any of the configuration.
+
+The instructions apply to users who have configured ``django-axes`` in their projects
+and have used flags that are deprecated. The deprecated flags will be removed in the future
+but are compatible for at least version 6.0 of ``django-axes``.
+
+The following flags and configuration have changed:
+
+``django-ipware`` has become an optional dependency.
+To keep old behaviour, use ``pip install django-axes[ipware]``
+in your install script or use ``django-axes[ipware]``
+in your requirements file(s) instead of plain ``django-axes``.
+The new ``django-axes`` package does not include ``django-ipware`` by default
+but does use ``django-ipware`` if it is installed
+and no callables for IP address resolution are configured
+with the ``settings.AXES_CLIENT_IP_CALLABLE`` configuration flag.
+
+``django-ipware`` related flags have changed names.
+The old flags have been deprecated and will be removed in the future.
+To keep old behaviour, rename them in your settings file:
+
+- ``settings.AXES_PROXY_ORDER`` is now ``settings.AXES_IPWARE_PROXY_ORDER``,
+- ``settings.AXES_PROXY_COUNT``  is now ``settings.AXES_IPWARE_PROXY_COUNT``,
+- ``settings.AXES_PROXY_TRUSTED_IPS`` is now ``settings.AXES_IPWARE_PROXY_TRUSTED_IPS``, and
+- ``settings.AXES_META_PRECEDENCE_ORDER`` is now ``settings.AXES_IPWARE_META_PRECEDENCE_ORDER``.
+
+``settings.AXES_LOCKOUT_PARAMETERS`` configuration flag has been added which supersedes the following configuration keys:
+
+#. No configuration for failure tracking in the following items (default behaviour).
+#. ``settings.AXES_ONLY_USER_FAILURES``,
+#. ``settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP``,
+#. ``settings.AXES_LOCK_OUT_BY_USER_OR_IP``, and
+#. ``settings.AXES_USE_USER_AGENT``.
+
+To keep old behaviour with the new flag, configure the following:
+
+#. If you did not use any flags, use ``settings.AXES_LOCKOUT_PARAMETERS = ["ip_address"]``,
+#. If you used ``settings.AXES_ONLY_USER_FAILURES``, use ``settings.AXES_LOCKOUT_PARAMETERS = ["username"]``,
+#. If you used ``settings.AXES_LOCK_OUT_BY_USER_OR_IP``, use ``settings.AXES_LOCKOUT_PARAMETERS = ["username", "ip_address"]``, and
+#. If you used ``settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP``, use ``settings.AXES_LOCKOUT_PARAMETERS = [["username", "ip_address"]]``,
+#. If you used ``settings.AXES_USE_USER_AGENT``, add ``"user_agent"`` to your list(s) of lockout parameters.
+    #. ``settings.AXES_USE_USER_AGENT`` would become ``settings.AXES_LOCKOUT_PARAMETERS = ["ip_address", "user_agent"]``
+    #. ``settings.AXES_USE_USER_AGENT`` with ``settings.AXES_ONLY_USER_FAILURES`` would become ``settings.AXES_LOCKOUT_PARAMETERS = [["username", "user_agent"]]``
+    #. ``settings.AXES_USE_USER_AGENT`` with ``settings.AXES_LOCK_OUT_BY_USER_OR_IP`` would become ``settings.AXES_LOCKOUT_PARAMETERS = [["ip_address", "user_agent"], "username"]``
+    #. ``settings.AXES_USE_USER_AGENT`` with ``settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP`` would become ``settings.AXES_LOCKOUT_PARAMETERS = [["ip_address", "user_agent", "username"]]``
+    #. Other combinations of flags were previously not considered; the flags had precedence over each other as described in the documentation but were less-than-trivial to understand in their previous form. The new form is more explicit and flexible, although it requires more in-depth configuration.
+
+The new lockout parameters define a combined list of attributes to consider when tracking failed authentication attempts.
+They can be any combination of ``username``, ``ip_address`` or ``user_agent`` in a list of strings or list of lists of strings.
+The attributes defined in the lists are combined and saved into the database, cache, or other backend for failed logins.
+The semantics of the evaluation are available in the documentation and ``axes.helpers.get_client_parameters`` callable.
+
+``settings.AXES_HTTP_RESPONSE_CODE`` default has been changed from ``403`` (Forbidden) to ``429`` (Too Many Requests).
+To keep the old behavior, set ``settings.AXES_HTTP_RESPONSE_CODE = 403`` in your settings.
+
+``axes.handlers.base.AxesBaseHandler.is_admin_site`` has been deprecated due to misleading naming
+in favour of better-named ``axes.handlers.base.AxesBaseHandler.is_admin_request``.
+The old implementation has been kept for backwards compatibility, but will be removed in the future.
+The old implementation checked if a request is NOT made for an admin site if ``settings.AXES_ONLY_ADMIN_SITE`` was set.
+The new implementation correctly checks if a request is made for an admin site.
+
+``axes.handlers.cache.AxesCacheHandler`` has been updated to use atomic ``cache.incr`` calls
+instead of old ``cache.set`` calls in authentication failure tracking
+to enable better parallel backend support for atomic cache backends like Redis and Memcached.
+
+
 Disabling Axes system checks
 ----------------------------
 
@@ -127,7 +198,7 @@ other code, preventing the login mechanisms from working due to e.g. exception
 being thrown in some part of the code, preventing access attempts being logged
 to database with Axes or causing similar problems.
 
-If new attempts or log objects are not being correctly written to the Axes tables, 
+If new attempts or log objects are not being correctly written to the Axes tables,
 it is possible to configure Django ``ATOMIC_REQUESTS`` setting to to ``False``::
 
     ATOMIC_REQUESTS = False
diff --git a/docs/4_configuration.rst b/docs/4_configuration.rst
index 6228d9e..a2f3abe 100644
--- a/docs/4_configuration.rst
+++ b/docs/4_configuration.rst
@@ -27,15 +27,15 @@ The following ``settings.py`` options are available for customizing Axes behavio
 +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
 | AXES_ONLY_ADMIN_SITE                                 | False                                        | If ``True``, lock is only enabled for admin site. Admin site is determined by checking request path against the path of ``"admin:index"`` view. If admin urls are not registered in current urlconf, all requests will not be locked.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
 +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
-| AXES_ONLY_USER_FAILURES                              | False                                        | If ``True``, only lock based on username, and never lock based on IP if attempts exceed the limit. Otherwise utilize the existing IP and user locking logic.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
+| AXES_ONLY_USER_FAILURES                              | False                                        | DEPRECATED: USE ``AXES_LOCKOUT_PARAMETERS`` INSTEAD. If ``True``, only lock based on username, and never lock based on IP if attempts exceed the limit. Otherwise utilize the existing IP and user locking logic.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
 +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
 | AXES_ENABLE_ADMIN                                    | True                                         | If ``True``, admin views for access attempts and logins are shown in Django admin interface.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
 +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
-| AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP             | False                                        | If ``True``, prevent login from IP under a particular username if the attempt limit has been exceeded, otherwise lock out based on IP.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
+| AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP             | False                                        | DEPRECATED: USE ``AXES_LOCKOUT_PARAMETERS`` INSTEAD. If ``True``, prevent login from IP under a particular username if the attempt limit has been exceeded, otherwise lock out based on IP.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
 +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
-| AXES_LOCK_OUT_BY_USER_OR_IP                          | False                                        |  If ``True``, prevent login from if the attempt limit has been exceeded for IP or username.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
+| AXES_LOCK_OUT_BY_USER_OR_IP                          | False                                        | DEPRECATED: USE ``AXES_LOCKOUT_PARAMETERS`` INSTEAD. If ``True``, prevent login from if the attempt limit has been exceeded for IP or username.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
 +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
-| AXES_USE_USER_AGENT                                  | False                                        | If ``True``, lock out and log based on the IP address and the user agent.  This means requests from different user agents but from the same IP are treated differently. This settings has no effect if the ``AXES_ONLY_USER_FAILURES`` setting is active.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
+| AXES_USE_USER_AGENT                                  | False                                        | DEPRECATED: USE ``AXES_LOCKOUT_PARAMETERS`` INSTEAD. If ``True``, lock out and log based on the IP address and the user agent.  This means requests from different user agents but from the same IP are treated differently. This settings has no effect if the ``AXES_ONLY_USER_FAILURES`` setting is active.                                                                                                                                                                                                                                                                                                                                                                                                                            |
 +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
 | AXES_HANDLER                                         | 'axes.handlers.database.AxesDatabaseHandler' | The path to the handler class to use. If set, overrides the default signal handler backend. Default: ``'axes.handlers.database.AxesDatabaseHandler'``                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
 +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
@@ -55,6 +55,8 @@ The following ``settings.py`` options are available for customizing Axes behavio
 +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
 | AXES_LOCKOUT_CALLABLE                                | None                                         | A callable or a string path to callable that takes two arguments returns a response. For example: ``def generate_lockout_response(request: HttpRequest, credentials: dict) -> HttpResponse: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. If not callable is defined, then the default implementation in ``axes.helpers.get_lockout_response`` is used for determining the correct lockout response that is sent to the requesting client.                                                                                                                                                                                                                                                                     |
 +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
+| AXES_CLIENT_IP_CALLABLE                              | None                                         | A callable or a string path to callable that takes two arguments returns a response. For example: ``def get_ip(request: HttpRequest) -> str: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. If not callable is defined, then the default implementation in ``axes.helpers.get_client_ip_address`` is used.                                                                                                                                                                                                                                                                                                                                                                                                      |
++------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
 | AXES_PASSWORD_FORM_FIELD                             | 'password'                                   | The name of the form or credentials field that contains your users password.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
 +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
 | AXES_SENSITIVE_PARAMETERS                            | []                                           | Configures POST and GET parameter values (in addition to the value of ``AXES_PASSWORD_FORM_FIELD``) to mask in login attempt logging.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
@@ -77,10 +79,12 @@ The following ``settings.py`` options are available for customizing Axes behavio
 +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
 | AXES_ALLOWED_CORS_ORIGINS                            | "*"                                          | Configures lockout response CORS headers for XHR requests.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
 +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
-| AXES_HTTP_RESPONSE_CODE                              | 403                                          | Sets the http response code returned when ``AXES_FAILURE_LIMIT`` is reached. For example: ``AXES_HTTP_RESPONSE_CODE = 429``                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
+| AXES_HTTP_RESPONSE_CODE                              | 429                                          | Sets the http response code returned when ``AXES_FAILURE_LIMIT`` is reached. For example: ``AXES_HTTP_RESPONSE_CODE = 403``                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
 +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
 | AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT        | True                                         |  If ``True``, a failed login attempt during lockout will reset the cool off period.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
 +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
+| AXES_LOCKOUT_PARAMETERS                              | ["ip_address"]                               |  A list of parameters that Axes uses to lock out users. It can also be callable, which takes an http request or AccesAttempt object and credentials and returns a list of parameters. Each parameter can be a string (a single parameter) or a list of strings (a combined parameter). For example, if you configure ``AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]``, axes will block clients by ip and/or username and user agent combination. See :ref:`customizing-lockout-parameters` for more details.                                                                                                                                                                                                      |
++------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
 
 The configuration option precedences for the access attempt monitoring are:
 
@@ -101,8 +105,8 @@ and uses some conservative configuration parameters by default for security.
 If you are using reverse proxies, you will need to configure one or more of the
 following settings to suit your set up to correctly resolve client IP addresses:
 
-* ``AXES_PROXY_COUNT``: The number of reverse proxies in front of Django as an integer. Default: ``None``
-* ``AXES_META_PRECEDENCE_ORDER``: The names of ``request.META`` attributes as a tuple of strings
+* ``AXES_IPWARE_PROXY_COUNT``: The number of reverse proxies in front of Django as an integer. Default: ``None``
+* ``AXES_IPWARE_META_PRECEDENCE_ORDER``: The names of ``request.META`` attributes as a tuple of strings
   to check to get the client IP address. Check the Django documentation for header naming conventions.
   Default: ``IPWARE_META_PRECEDENCE_ORDER`` setting if set, else ``('REMOTE_ADDR', )``
 
@@ -112,7 +116,7 @@ following settings to suit your set up to correctly resolve client IP addresses:
    .. code-block:: python
 
       # refer to the Django request and response objects documentation
-      AXES_META_PRECEDENCE_ORDER = [
+      AXES_IPWARE_META_PRECEDENCE_ORDER = [
           'HTTP_X_FORWARDED_FOR',
           'REMOTE_ADDR',
       ]
diff --git a/docs/5_customization.rst b/docs/5_customization.rst
index ae91cf6..a1fbcfd 100644
--- a/docs/5_customization.rst
+++ b/docs/5_customization.rst
@@ -34,7 +34,7 @@ Here is a more detailed example of sending the necessary signals using
 and a custom auth backend at an endpoint that expects JSON
 requests. The custom authentication can be swapped out with ``authenticate``
 and ``login`` from ``django.contrib.auth``, but beware that those methods take
-care of sending the nessary signals for you, and there is no need to duplicate
+care of sending the necessary signals for you, and there is no need to duplicate
 them as per the example.
 
 ``example/forms.py``::
@@ -155,7 +155,6 @@ into ``my_namespace-username``:
    fine, but Axes does not inject these changes into the authentication flow
    for you.
 
-
 Customizing lockout responses
 -----------------------------
 
@@ -173,3 +172,59 @@ An example of usage could be e.g. a custom view for processing lockouts.
 ``settings.py``::
 
     AXES_LOCKOUT_CALLABLE = "example.views.lockout"
+
+.. _customizing-lockout-parameters:
+
+Customizing lockout parameters
+------------------------------
+
+Axes can be configured with ``AXES_LOCKOUT_PARAMETERS`` to lock out users not only by IP address.
+
+``AXES_LOCKOUT_PARAMETERS`` can be a list of strings (which represents a separate lockout parameter) or nested lists of strings (which represents lockout parameters used in combination) or a callable which accepts HttpRequest or AccessAttempt and credentials and returns a list of the same form as described earlier.
+
+Example ``AXES_LOCKOUT_PARAMETERS`` configuration:
+
+``settings.py``::
+
+    AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]
+
+This way, axes will lock out users using ip_address and/or combination of username and user agent
+
+Example of callable ``AXES_LOCKOUT_PARAMETERS``:
+
+``example/utils.py``::
+
+    from django.http import HttpRequest
+
+    def get_lockout_parameters(request_or_attempt, credentials):
+
+        if isinstance(request_or_attempt, HttpRequest):
+           is_localhost = request.META.get("REMOTE_ADDR") == "127.0.0.1"
+
+        else:
+           is_localhost = request_or_attempt.ip_address == "127.0.0.1"
+
+        if is_localhost:
+           return ["username"]
+
+        return ["ip_address", "username"]
+
+``settings.py``::
+
+    AXES_LOCKOUT_PARAMETERS = "example.utils.get_lockout_parameters"
+
+This way, if client ip_address is localhost, axes will lockout client only by username. In other case, axes will lockout client by username and/or ip_address.
+
+Customizing client ip address lookups
+-------------------------------------
+
+Axes can be configured with ``AXES_CLIENT_IP_CALLABLE`` to use custom client ip address lookup logic.
+
+``example/utils.py``::
+
+    def get_client_ip(request):
+        return request.META.get("REMOTE_ADDR")
+
+``settings.py``::
+
+    AXES_CLIENT_IP_CALLABLE = "example.utils.get_client_ip"
diff --git a/docs/6_integration.rst b/docs/6_integration.rst
index 4844062..d7aa503 100644
--- a/docs/6_integration.rst
+++ b/docs/6_integration.rst
@@ -20,6 +20,7 @@ Django Allauth                                           |check|
 Django Simple Captcha                                    |check|
 Django OAuth Toolkit                                     |check|
 Django Reversion                                         |check|
+Django Auth LDAP                          |check|               
 =======================   =============   ============   ============   ==============
 
 .. |check|  unicode:: U+2713
diff --git a/docs/7_architecture.rst b/docs/7_architecture.rst
index d670c27..f83c6b4 100644
--- a/docs/7_architecture.rst
+++ b/docs/7_architecture.rst
@@ -56,7 +56,7 @@ are not blocked, and allows the requests to go through if the check passes.
 
 If the authentication attempt matches a lockout rule, e.g. it is from a
 blacklisted IP or exceeds the maximum configured authentication attempts,
-it is blocked by raising the ``PermissionDenied`` excepton in the backend.
+it is blocked by raising the ``PermissionDenied`` exception in the backend.
 
 Axes monitors logins with the ``user_login_failed`` signal receiver
 and records authentication failures from both the ``AxesBackend`` and
diff --git a/docs/9_contributing.rst b/docs/9_contributing.rst
new file mode 100644
index 0000000..4628182
--- /dev/null
+++ b/docs/9_contributing.rst
@@ -0,0 +1,3 @@
+.. _contributing:
+
+.. include:: ../CONTRIBUTING.rst
diff --git a/docs/Makefile b/docs/Makefile
index 0fd0c1e..820fe11 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -9,7 +9,7 @@ BUILDDIR      = _build
 
 # User-friendly check for sphinx-build
 ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
-$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
+$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from https://sphinx-doc.org/)
 endif
 
 # Internal variables.
diff --git a/docs/conf.py b/docs/conf.py
index eedb46c..8fbfe9e 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -3,7 +3,7 @@ Sphinx documentation generator configuration.
 
 More information on the configuration options is available at:
 
-    http://www.sphinx-doc.org/en/master/usage/configuration.html
+    https://www.sphinx-doc.org/en/master/usage/configuration.html
 """
 
 import sphinx_rtd_theme
diff --git a/docs/index.rst b/docs/index.rst
index 19972ee..72b3162 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -18,7 +18,7 @@ Contents
    6_integration
    7_architecture
    8_reference
-   9_development
+   9_contributing
    10_changelog
 
 
diff --git a/mypy.ini b/mypy.ini
index 1cbb0ff..7fa5400 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -1,5 +1,5 @@
 [mypy]
-python_version = 3.6
+python_version = 3.8
 ignore_missing_imports = True
 
 [mypy-axes.migrations.*]
diff --git a/pyproject.toml b/pyproject.toml
index c711449..a22f66d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,25 +10,25 @@ DJANGO_SETTINGS_MODULE = "tests.settings"
 legacy_tox_ini = """
 [tox]
 envlist =
-    py{37,38,39,310,py38}-dj32
-    py{38,39,310,py38}-dj40
-    py{38,39,310,py38}-dj41
-    py{38,39,310,py38}-djmain
-    py310-djqa
+    py{38,39,310,py38}-dj32
+    py{38,39,310,311,py38}-dj41
+    py{38,39,310,311,py38}-dj42
+    py311-djmain
+    py311-djqa
 
 [gh-actions]
 python =
-    3.7: py37
     3.8: py38
     3.9: py39
     3.10: py310
+    3.11: py311
     pypy-3.8: pypy38
 
 [gh-actions:env]
 DJANGO =
     3.2: dj32
-    4.0: dj40
     4.1: dj41
+    4.2: dj42
     main: djmain
     qa: djqa
 
@@ -37,8 +37,8 @@ DJANGO =
 deps =
     -r requirements-test.txt
     dj32: django>=3.2,<3.3
-    dj40: django>=4.0,<4.1
     dj41: django>=4.1,<4.2
+    dj42: django>=4.1,<4.2
     djmain: https://github.com/django/django/archive/main.tar.gz
 usedevelop = true
 commands = pytest
@@ -47,13 +47,13 @@ setenv =
 # Django development version is allowed to fail the test matrix
 ignore_outcome =
     djmain: True
-    pypy38-dj41: True
+    pypy38: True
 ignore_errors =
     djmain: True
-    pypy38-dj41: True
+    pypy38: True
 
 # QA runs type checks, linting, and code formatting checks
-[testenv:py310-djqa]
+[testenv:py311-djqa]
 deps = -r requirements-qa.txt
 commands =
     mypy axes
diff --git a/requirements-qa.txt b/requirements-qa.txt
index 4342b48..6db02a9 100644
--- a/requirements-qa.txt
+++ b/requirements-qa.txt
@@ -1,4 +1,4 @@
-black==22.6.0
-mypy==0.971
-prospector==1.7.7
+black==23.3.0
+mypy==1.3.0
+prospector==1.10.0
 types-pkg_resources  # Type stub
diff --git a/requirements-test.txt b/requirements-test.txt
index 3c3f8d3..fc04b11 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -1,6 +1,7 @@
 -e .
-coverage==6.4.4
-pytest==7.1.2
-pytest-cov==3.0.0
+django-ipware>=3
+coverage==7.2.5
+pytest==7.3.1
+pytest-cov==4.0.0
 pytest-django==4.5.2
-pytest-subtests==0.8.0
+pytest-subtests==0.11.0
diff --git a/requirements.txt b/requirements.txt
index ad223a7..7c4c941 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
 -e .
 -r requirements-qa.txt
 -r requirements-test.txt
-sphinx_rtd_theme==1.0.0
-tox==3.25.1
+sphinx_rtd_theme==1.2.0
+tox==4.5.1
diff --git a/setup.py b/setup.py
index db2f245..99ac508 100644
--- a/setup.py
+++ b/setup.py
@@ -36,7 +36,10 @@ setup(
     use_scm_version=True,
     setup_requires=["setuptools_scm"],
     python_requires=">=3.7",
-    install_requires=["django>=3.2", "django-ipware>=3", "setuptools"],
+    install_requires=["django>=3.2", "setuptools"],
+    extras_require={
+        "ipware": "django-ipware>=3",
+    },
     include_package_data=True,
     packages=find_packages(exclude=["tests"]),
     classifiers=[
@@ -45,18 +48,18 @@ setup(
         "Environment :: Plugins",
         "Framework :: Django",
         "Framework :: Django :: 3.2",
-        "Framework :: Django :: 4.0",
         "Framework :: Django :: 4.1",
+        "Framework :: Django :: 4.2",
         "Intended Audience :: Developers",
         "Intended Audience :: System Administrators",
         "License :: OSI Approved :: MIT License",
         "Operating System :: OS Independent",
         "Programming Language :: Python",
         "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: 3.10",
+        "Programming Language :: Python :: 3.11",
         "Programming Language :: Python :: Implementation :: CPython",
         "Programming Language :: Python :: Implementation :: PyPy",
         "Topic :: Internet :: Log Analysis",
diff --git a/tests/base.py b/tests/base.py
index be5cd56..3027fce 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -48,7 +48,7 @@ class AxesTestCase(TestCase):
 
     STATUS_SUCCESS = 200
     ALLOWED = 302
-    BLOCKED = 403
+    BLOCKED = 429
 
     def setUp(self):
         """
diff --git a/tests/test_attempts.py b/tests/test_attempts.py
index f532ec2..04af617 100644
--- a/tests/test_attempts.py
+++ b/tests/test_attempts.py
@@ -82,74 +82,74 @@ class ResetResponseTestCase(AxesTestCase):
         reset_request(self.request)
         self.assertEqual(AccessAttempt.objects.count(), 3)
 
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     def test_reset_user_failures(self):
         reset_request(self.request)
         self.assertEqual(AccessAttempt.objects.count(), 5)
 
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     def test_reset_ip_user_failures(self):
         self.request.META["REMOTE_ADDR"] = self.IP_1
         reset_request(self.request)
         self.assertEqual(AccessAttempt.objects.count(), 5)
 
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     def test_reset_username_user_failures(self):
         self.request.GET["username"] = self.USERNAME_1
         reset_request(self.request)
         self.assertEqual(AccessAttempt.objects.count(), 3)
 
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     def test_reset_ip_username_user_failures(self):
         self.request.GET["username"] = self.USERNAME_1
         self.request.META["REMOTE_ADDR"] = self.IP_1
         reset_request(self.request)
         self.assertEqual(AccessAttempt.objects.count(), 3)
 
-    @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
     def test_reset_user_or_ip(self):
         reset_request(self.request)
         self.assertEqual(AccessAttempt.objects.count(), 5)
 
-    @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
     def test_reset_ip_user_or_ip(self):
         self.request.META["REMOTE_ADDR"] = self.IP_1
         reset_request(self.request)
         self.assertEqual(AccessAttempt.objects.count(), 3)
 
-    @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
     def test_reset_username_user_or_ip(self):
         self.request.GET["username"] = self.USERNAME_1
         reset_request(self.request)
         self.assertEqual(AccessAttempt.objects.count(), 3)
 
-    @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
     def test_reset_ip_username_user_or_ip(self):
         self.request.GET["username"] = self.USERNAME_1
         self.request.META["REMOTE_ADDR"] = self.IP_1
         reset_request(self.request)
         self.assertEqual(AccessAttempt.objects.count(), 2)
 
-    @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
     def test_reset_user_and_ip(self):
         reset_request(self.request)
         self.assertEqual(AccessAttempt.objects.count(), 5)
 
-    @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
     def test_reset_ip_user_and_ip(self):
         self.request.META["REMOTE_ADDR"] = self.IP_1
         reset_request(self.request)
         self.assertEqual(AccessAttempt.objects.count(), 3)
 
-    @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
     def test_reset_username_user_and_ip(self):
         self.request.GET["username"] = self.USERNAME_1
         reset_request(self.request)
         self.assertEqual(AccessAttempt.objects.count(), 3)
 
-    @override_settings(AXES_LOCK_OUT_BY_USER_OR_AND=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
     def test_reset_ip_username_user_and_ip(self):
         self.request.GET["username"] = self.USERNAME_1
         self.request.META["REMOTE_ADDR"] = self.IP_1
         reset_request(self.request)
-        self.assertEqual(AccessAttempt.objects.count(), 3)
+        self.assertEqual(AccessAttempt.objects.count(), 4)
diff --git a/tests/test_decorators.py b/tests/test_decorators.py
index 7de9c26..f57bcbc 100644
--- a/tests/test_decorators.py
+++ b/tests/test_decorators.py
@@ -8,7 +8,7 @@ from tests.base import AxesTestCase
 
 class DecoratorTestCase(AxesTestCase):
     SUCCESS_RESPONSE = HttpResponse(status=200, content="Dispatched")
-    LOCKOUT_RESPONSE = HttpResponse(status=403, content="Locked out")
+    LOCKOUT_RESPONSE = HttpResponse(status=429, content="Locked out")
 
     def setUp(self):
         self.request = MagicMock()
diff --git a/tests/test_handlers.py b/tests/test_handlers.py
index 5ae36b0..0c40573 100644
--- a/tests/test_handlers.py
+++ b/tests/test_handlers.py
@@ -55,14 +55,36 @@ class AxesHandlerTestCase(AxesTestCase):
         for setting_value, url, expected in tests:
             with override_settings(AXES_ONLY_ADMIN_SITE=setting_value):
                 request.path = url
-                self.assertEqual(AxesProxyHandler().is_admin_site(request), expected)
+                with self.assertWarns(DeprecationWarning):
+                    self.assertEqual(AxesProxyHandler().is_admin_site(request), expected)
+
+    def test_is_admin_request(self):
+        request = MagicMock()
+        tests = (  # (URL, Expected)
+            ("/test/", False),
+            (reverse("admin:index"), True),
+        )
+
+        for url, expected in tests:
+            request.path = url
+            self.assertEqual(AxesProxyHandler().is_admin_request(request), expected)
 
     @override_settings(ROOT_URLCONF="tests.urls_empty")
     @override_settings(AXES_ONLY_ADMIN_SITE=True)
     def test_is_admin_site_no_admin_site(self):
         request = MagicMock()
         request.path = "/admin/"
-        self.assertTrue(AxesProxyHandler().is_admin_site(self.request))
+        with self.assertWarns(DeprecationWarning):
+            self.assertTrue(AxesProxyHandler().is_admin_site(self.request))
+
+    @override_settings(ROOT_URLCONF="tests.urls_empty")
+    def test_is_admin_request_no_admin_site(self):
+        request = MagicMock()
+        request.path = "/admin/"
+        self.assertFalse(AxesProxyHandler().is_admin_request(self.request))
+
+    def test_is_admin_request_no_path(self):
+        self.assertFalse(AxesProxyHandler().is_admin_request(self.request))
 
 
 class AxesProxyHandlerTestCase(AxesTestCase):
@@ -261,7 +283,10 @@ class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase):
         _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))
+        self.assertEqual(
+            _more,
+            AxesProxyHandler.remove_out_of_limit_failure_logs(username=self.username),
+        )
 
     @override_settings(AXES_RESET_ON_SUCCESS=True)
     def test_handler(self):
@@ -296,7 +321,7 @@ class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase):
     def test_whitelist(self, log):
         self.check_whitelist(log)
 
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     @patch("axes.handlers.database.log")
     def test_user_login_failed_only_user_failures_with_none_username(self, log):
         credentials = {"username": None, "password": "test"}
@@ -305,7 +330,7 @@ class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase):
         attempt = AccessAttempt.objects.all()
         self.assertEqual(0, AccessAttempt.objects.count())
         log.warning.assert_called_with(
-            "AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, new record will NOT be created."
+            "AXES: Username is None and username is the only one lockout parameter, new record will NOT be created."
         )
 
     def test_user_login_failed_with_none_username(self):
@@ -318,22 +343,37 @@ class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase):
     def test_user_login_failed_multiple_username(self):
         configurations = (
             (2, 1, {}, ["admin", "admin1"]),
-            (2, 1, {"AXES_USE_USER_AGENT": True}, ["admin", "admin1"]),
-            (2, 1, {"AXES_ONLY_USER_FAILURES": True}, ["admin", "admin1"]),
             (
                 2,
                 1,
-                {"AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP": True},
+                {"AXES_LOCKOUT_PARAMETERS": [["ip_address", "user_agent"]]},
                 ["admin", "admin1"],
             ),
+            (2, 1, {"AXES_LOCKOUT_PARAMETERS": ["username"]}, ["admin", "admin1"]),
+            (
+                2,
+                1,
+                {"AXES_LOCKOUT_PARAMETERS": [["username", "ip_address"]]},
+                ["admin", "admin1"],
+            ),
+            (
+                1,
+                2,
+                {"AXES_LOCKOUT_PARAMETERS": [["username", "ip_address"]]},
+                ["admin", "admin"],
+            ),
             (
                 1,
                 2,
-                {"AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP": True},
+                {"AXES_LOCKOUT_PARAMETERS": ["username", "ip_address"]},
                 ["admin", "admin"],
             ),
-            (1, 2, {"AXES_LOCK_OUT_BY_USER_OR_IP": True}, ["admin", "admin"]),
-            (2, 1, {"AXES_LOCK_OUT_BY_USER_OR_IP": True}, ["admin", "admin1"]),
+            (
+                2,
+                1,
+                {"AXES_LOCKOUT_PARAMETERS": ["username", "ip_address"]},
+                ["admin", "admin1"],
+            ),
         )
 
         for (
@@ -400,7 +440,7 @@ class ResetAttemptsCacheHandlerTestCase(AxesHandlerBaseTestCase):
         with self.assertRaises(NotImplementedError):
             AxesProxyHandler.reset_attempts()
 
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     def test_handler_reset_attempts_username(self):
         self.set_up_login_attempts()
         self.assertEqual(
@@ -436,7 +476,7 @@ class ResetAttemptsCacheHandlerTestCase(AxesHandlerBaseTestCase):
         self.check_failures(0, ip_address=self.IP_1)
         self.check_failures(2, ip_address=self.IP_2)
 
-    @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
     def test_handler_reset_attempts_ip_and_username(self):
         self.set_up_login_attempts()
         self.check_failures(1, username=self.USERNAME_1, ip_address=self.IP_1)
@@ -482,7 +522,7 @@ class AxesCacheHandlerTestCase(AxesHandlerBaseTestCase):
     def test_whitelist(self, log):
         self.check_whitelist(log)
 
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     @patch.object(cache, "set")
     @patch("axes.handlers.cache.log")
     def test_user_login_failed_only_user_failures_with_none_username(
@@ -493,15 +533,15 @@ class AxesCacheHandlerTestCase(AxesHandlerBaseTestCase):
         AxesProxyHandler.user_login_failed(sender, credentials, self.request)
         self.assertFalse(cache_set.called)
         log.warning.assert_called_with(
-            "AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, new record will NOT be created."
+            "AXES: Username is None and username is the only one lockout parameter, new record will NOT be created."
         )
 
-    @patch.object(cache, "set")
-    def test_user_login_failed_with_none_username(self, cache_set):
+    @patch.object(cache, "add")
+    def test_user_login_failed_with_none_username(self, cache_add):
         credentials = {"username": None, "password": "test"}
         sender = MagicMock()
         AxesProxyHandler.user_login_failed(sender, credentials, self.request)
-        self.assertTrue(cache_set.called)
+        self.assertTrue(cache_add.called)
 
 
 @override_settings(AXES_HANDLER="axes.handlers.dummy.AxesDummyHandler")
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index 30a216d..6562568 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -3,16 +3,18 @@ from hashlib import sha256
 from unittest.mock import patch
 
 from django.contrib.auth import get_user_model
-from django.http import JsonResponse, HttpResponseRedirect, HttpResponse, HttpRequest
-from django.test import override_settings, RequestFactory
+from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse
+from django.test import RequestFactory, override_settings
 
 from axes.apps import AppConfig
 from axes.helpers import (
+    cleanse_parameters,
     get_cache_timeout,
+    get_client_cache_keys,
+    get_client_ip_address,
+    get_client_parameters,
     get_client_str,
     get_client_username,
-    get_client_cache_key,
-    get_client_parameters,
     get_cool_off,
     get_cool_off_iso8601,
     get_lockout_response,
@@ -23,7 +25,6 @@ from axes.helpers import (
     is_ip_address_in_whitelist,
     is_user_attempt_whitelisted,
     toggleable,
-    cleanse_parameters,
 )
 from axes.models import AccessAttempt
 from tests.base import AxesTestCase
@@ -149,7 +150,7 @@ class ClientStringTestCase(AxesTestCase):
 
         self.assertEqual(expected, actual)
 
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     @override_settings(AXES_VERBOSE=True)
     def test_verbose_user_only_client_details(self):
         username = "test@example.com"
@@ -166,7 +167,7 @@ class ClientStringTestCase(AxesTestCase):
 
         self.assertEqual(expected, actual)
 
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     @override_settings(AXES_VERBOSE=False)
     def test_non_verbose_user_only_client_details(self):
         username = "test@example.com"
@@ -181,7 +182,7 @@ class ClientStringTestCase(AxesTestCase):
 
         self.assertEqual(expected, actual)
 
-    @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
     @override_settings(AXES_VERBOSE=True)
     def test_verbose_user_ip_combo_client_details(self):
         username = "test@example.com"
@@ -198,7 +199,7 @@ class ClientStringTestCase(AxesTestCase):
 
         self.assertEqual(expected, actual)
 
-    @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
     @override_settings(AXES_VERBOSE=False)
     def test_non_verbose_user_ip_combo_client_details(self):
         username = "test@example.com"
@@ -213,7 +214,7 @@ class ClientStringTestCase(AxesTestCase):
 
         self.assertEqual(expected, actual)
 
-    @override_settings(AXES_USE_USER_AGENT=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "user_agent"]])
     @override_settings(AXES_VERBOSE=True)
     def test_verbose_user_agent_client_details(self):
         username = "test@example.com"
@@ -230,7 +231,7 @@ class ClientStringTestCase(AxesTestCase):
 
         self.assertEqual(expected, actual)
 
-    @override_settings(AXES_USE_USER_AGENT=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "user_agent"]])
     @override_settings(AXES_VERBOSE=False)
     def test_non_verbose_user_agent_client_details(self):
         username = "test@example.com"
@@ -268,6 +269,26 @@ class ClientStringTestCase(AxesTestCase):
             self.email,
         )
 
+    @override_settings(AXES_SENSITIVE_PARAMETERS=["username"])
+    def test_get_client_str_with_sensitive_parameters(self):
+        username = "test@example.com"
+        ip_address = "127.0.0.1"
+        user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
+        path_info = "/admin/"
+
+        expected = self.get_expected_client_str(
+            "********************",
+            ip_address,
+            user_agent,
+            path_info,
+            self.request
+        )
+        actual = get_client_str(
+            username, ip_address, user_agent, path_info, self.request
+        )
+
+        self.assertEqual(expected, actual)
+
 
 def get_dummy_client_str(username, ip_address, user_agent, path_info, request):
     return "client string"
@@ -279,76 +300,265 @@ def get_dummy_client_str_using_request(
     return f"{request.user.email}"
 
 
+def get_dummy_lockout_parameters(request, credentials=None):
+    return ["ip_address", ["username", "user_agent"]]
+
+
 class ClientParametersTestCase(AxesTestCase):
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     def test_get_filter_kwargs_user(self):
         self.assertEqual(
-            get_client_parameters(self.username, self.ip_address, self.user_agent),
+            get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
             [{"username": self.username}],
         )
 
-    @override_settings(
-        AXES_ONLY_USER_FAILURES=False,
-        AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=False,
-        AXES_USE_USER_AGENT=False,
-    )
     def test_get_filter_kwargs_ip(self):
         self.assertEqual(
-            get_client_parameters(self.username, self.ip_address, self.user_agent),
+            get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
             [{"ip_address": self.ip_address}],
         )
 
-    @override_settings(
-        AXES_ONLY_USER_FAILURES=False,
-        AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True,
-        AXES_USE_USER_AGENT=False,
-    )
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
     def test_get_filter_kwargs_user_and_ip(self):
         self.assertEqual(
-            get_client_parameters(self.username, self.ip_address, self.user_agent),
+            get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
             [{"username": self.username, "ip_address": self.ip_address}],
         )
 
-    @override_settings(
-        AXES_ONLY_USER_FAILURES=False,
-        AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=False,
-        AXES_LOCK_OUT_BY_USER_OR_IP=True,
-        AXES_USE_USER_AGENT=False,
-    )
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "user_agent"]])
+    def test_get_filter_kwargs_user_and_user_agent(self):
+        self.assertEqual(
+            get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
+            [{"username": self.username, "user_agent": self.user_agent}],
+        )
+
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["ip_address", ["username", "user_agent"]])
+    def test_get_filter_kwargs_ip_or_user_and_user_agent(self):
+        self.assertEqual(
+            get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
+            [{"ip_address": self.ip_address}, {"username": self.username, "user_agent": self.user_agent}],
+        )
+
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "user_agent"], ["username", "user_agent"]])
+    def test_get_filter_kwargs_ip_and_user_agent_or_user_and_user_agent(self):
+        self.assertEqual(
+            get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
+            [{"ip_address": self.ip_address, "user_agent": self.user_agent}, {"username": self.username, "user_agent": self.user_agent}],
+        )
+
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
     def test_get_filter_kwargs_user_or_ip(self):
         self.assertEqual(
-            get_client_parameters(self.username, self.ip_address, self.user_agent),
+            get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
             [{"username": self.username}, {"ip_address": self.ip_address}],
         )
 
-    @override_settings(
-        AXES_ONLY_USER_FAILURES=False,
-        AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=False,
-        AXES_USE_USER_AGENT=True,
-    )
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address", "user_agent"])
+    def test_get_filter_kwargs_user_or_ip_or_user_agent(self):
+        self.assertEqual(
+            get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
+            [{"username": self.username}, {"ip_address": self.ip_address}, {"user_agent": self.user_agent}],
+        )
+
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "user_agent"]])
     def test_get_filter_kwargs_ip_and_agent(self):
         self.assertEqual(
-            get_client_parameters(self.username, self.ip_address, self.user_agent),
-            [{"ip_address": self.ip_address}, {"user_agent": self.user_agent}],
+            get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
+            [{"ip_address": self.ip_address, "user_agent": self.user_agent}],
         )
 
     @override_settings(
-        AXES_ONLY_USER_FAILURES=False,
-        AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True,
-        AXES_USE_USER_AGENT=True,
+        AXES_LOCKOUT_PARAMETERS=[["username", "ip_address", "user_agent"]]
     )
     def test_get_filter_kwargs_user_ip_agent(self):
         self.assertEqual(
-            get_client_parameters(self.username, self.ip_address, self.user_agent),
+            get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
             [
-                {"username": self.username, "ip_address": self.ip_address},
-                {"user_agent": self.user_agent},
+                {
+                    "username": self.username,
+                    "ip_address": self.ip_address,
+                    "user_agent": self.user_agent,
+                },
             ],
         )
 
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["wrong_param"])
+    @patch("axes.helpers.log")
+    def test_get_filter_kwargs_invalid_parameter(self, log):
+        with self.assertRaises(ValueError):
+            get_client_parameters(
+                self.username,
+                self.ip_address,
+                self.user_agent,
+                self.request,
+                self.credentials,
+            )
+            log.exception.assert_called_with(
+                (
+                    "wrong_param lockout parameter is not allowed. "
+                    "Allowed lockout parameters: username, ip_address, user_agent"
+                )
+            )
+
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "wrong_param"]])
+    @patch("axes.helpers.log")
+    def test_get_filter_kwargs_invalid_combined_parameter(self, log):
+        with self.assertRaises(ValueError):
+            get_client_parameters(
+                self.username,
+                self.ip_address,
+                self.user_agent,
+                self.request,
+                self.credentials,
+            )
+            log.exception.assert_called_with(
+                (
+                    "wrong_param lockout parameter is not allowed. "
+                    "Allowed lockout parameters: username, ip_address, user_agent"
+                )
+            )
+
+    @override_settings(AXES_LOCKOUT_PARAMETERS=get_dummy_lockout_parameters)
+    def test_get_filter_kwargs_callable_lockout_parameters(self):
+        self.assertEqual(
+            get_client_parameters(
+                self.username,
+                self.ip_address,
+                self.user_agent,
+                self.request,
+                self.credentials,
+            ),
+            [
+                {
+                    "ip_address": self.ip_address,
+                },
+                {
+                    "username": self.username,
+                    "user_agent": self.user_agent,
+                },
+            ],
+        )
+
+    @override_settings(
+        AXES_LOCKOUT_PARAMETERS="tests.test_helpers.get_dummy_lockout_parameters"
+    )
+    def test_get_filter_kwargs_callable_str_lockout_parameters(self):
+        self.assertEqual(
+            get_client_parameters(
+                self.username,
+                self.ip_address,
+                self.user_agent,
+                self.request,
+                self.credentials,
+            ),
+            [
+                {
+                    "ip_address": self.ip_address,
+                },
+                {
+                    "username": self.username,
+                    "user_agent": self.user_agent,
+                },
+            ],
+        )
+
+    @override_settings(
+        AXES_LOCKOUT_PARAMETERS=lambda request, credentials: ["username"]
+    )
+    def test_get_filter_kwargs_callable_lambda_lockout_parameters(self):
+        self.assertEqual(
+            get_client_parameters(
+                self.username,
+                self.ip_address,
+                self.user_agent,
+                self.request,
+                self.credentials,
+            ),
+            [
+                {
+                    "username": self.username,
+                },
+            ],
+        )
+
+    @override_settings(AXES_LOCKOUT_PARAMETERS=True)
+    def test_get_filter_kwargs_not_list_or_callable(self):
+        with self.assertRaises(TypeError):
+            get_client_parameters(
+                self.username,
+                self.ip_address,
+                self.user_agent,
+                self.request,
+                self.credentials,
+            )
+
+    @override_settings(AXES_LOCKOUT_PARAMETERS=lambda: None)
+    def test_get_filter_kwargs_invalid_callable_too_few_arguments(self):
+        with self.assertRaises(TypeError):
+            get_client_parameters(
+                self.username,
+                self.ip_address,
+                self.user_agent,
+                self.request,
+                self.credentials,
+            )
+
+    @override_settings(AXES_LOCKOUT_PARAMETERS=lambda request, credentials, extra: None)
+    def test_get_filter_kwargs_invalid_callable_too_many_arguments(self):
+        with self.assertRaises(TypeError):
+            get_client_parameters(
+                self.username,
+                self.ip_address,
+                self.user_agent,
+                self.request,
+                self.credentials,
+            )
+
+    @override_settings(
+        AXES_LOCKOUT_PARAMETERS=lambda request, credentials: ["wrong_param"]
+    )
+    @patch("axes.helpers.log")
+    def test_get_filter_kwargs_callable_invalid_lockout_param(self, log):
+        with self.assertRaises(ValueError):
+            get_client_parameters(
+                self.username,
+                self.ip_address,
+                self.user_agent,
+                self.request,
+                self.credentials,
+            )
+            log.exception.assert_called_with(
+                (
+                    "wrong_param lockout parameter is not allowed. "
+                    "Allowed lockout parameters: username, ip_address, user_agent"
+                )
+            )
+
+    @override_settings(
+        AXES_LOCKOUT_PARAMETERS=lambda request, credentials: [
+            ["ip_address", "wrong_param"]
+        ]
+    )
+    @patch("axes.helpers.log")
+    def test_get_filter_kwargs_callable_invalid_combined_lockout_param(self, log):
+        with self.assertRaises(ValueError):
+            get_client_parameters(
+                self.username,
+                self.ip_address,
+                self.user_agent,
+                self.request,
+                self.credentials,
+            )
+            log.exception.assert_called_with(
+                (
+                    "wrong_param lockout parameter is not allowed. "
+                    "Allowed lockout parameters: username, ip_address, user_agent"
+                )
+            )
+
 
 class ClientCacheKeyTestCase(AxesTestCase):
-    def test_get_cache_key(self):
+    def test_get_cache_keys(self):
         """
         Test the cache key format.
         """
@@ -362,7 +572,7 @@ class ClientCacheKeyTestCase(AxesTestCase):
             "/admin/login/", data={"username": self.username, "password": "test"}
         )
 
-        self.assertEqual([cache_hash_key], get_client_cache_key(request))
+        self.assertEqual([cache_hash_key], get_client_cache_keys(request))
 
         # Getting cache key from AccessAttempt Object
         attempt = AccessAttempt(
@@ -376,7 +586,7 @@ class ClientCacheKeyTestCase(AxesTestCase):
             failures_since_start=0,
         )
 
-        self.assertEqual([cache_hash_key], get_client_cache_key(attempt))
+        self.assertEqual([cache_hash_key], get_client_cache_keys(attempt))
 
     def test_get_cache_key_empty_ip_address(self):
         """
@@ -396,7 +606,7 @@ class ClientCacheKeyTestCase(AxesTestCase):
             REMOTE_ADDR=empty_ip_address,
         )
 
-        self.assertEqual([cache_hash_key], get_client_cache_key(request))
+        self.assertEqual([cache_hash_key], get_client_cache_keys(request))
 
         # Getting cache key from AccessAttempt Object
         attempt = AccessAttempt(
@@ -410,7 +620,7 @@ class ClientCacheKeyTestCase(AxesTestCase):
             failures_since_start=0,
         )
 
-        self.assertEqual([cache_hash_key], get_client_cache_key(attempt))
+        self.assertEqual([cache_hash_key], get_client_cache_keys(attempt))
 
     def test_get_cache_key_credentials(self):
         """
@@ -430,7 +640,7 @@ class ClientCacheKeyTestCase(AxesTestCase):
         # Difference between the upper test: new call signature with credentials
         credentials = {"username": self.username}
 
-        self.assertEqual([cache_hash_key], get_client_cache_key(request, credentials))
+        self.assertEqual([cache_hash_key], get_client_cache_keys(request, credentials))
 
         # Getting cache key from AccessAttempt Object
         attempt = AccessAttempt(
@@ -443,7 +653,7 @@ class ClientCacheKeyTestCase(AxesTestCase):
             path_info=request.META.get("PATH_INFO", "<unknown>"),
             failures_since_start=0,
         )
-        self.assertEqual([cache_hash_key], get_client_cache_key(attempt))
+        self.assertEqual([cache_hash_key], get_client_cache_keys(attempt))
 
 
 class UsernameTestCase(AxesTestCase):
@@ -554,6 +764,53 @@ def get_username(request, credentials: dict) -> str:
     return "username"
 
 
+def get_ip(request: HttpRequest) -> str:
+    return "127.0.0.1"
+
+
+class ClientIpAddressTestCase(AxesTestCase):
+    @override_settings(AXES_CLIENT_IP_CALLABLE=get_ip)
+    def test_get_client_ip_address(self):
+        self.assertEqual(get_client_ip_address(HttpRequest()), "127.0.0.1")
+
+    @override_settings(AXES_CLIENT_IP_CALLABLE="tests.test_helpers.get_ip")
+    def test_get_client_ip_address_str(self):
+        self.assertEqual(get_client_ip_address(HttpRequest()), "127.0.0.1")
+
+    @override_settings(
+        AXES_CLIENT_IP_CALLABLE=lambda request: "127.0.0.1"
+    )  # pragma: no cover
+    def test_get_client_ip_address_lambda(self):
+        self.assertEqual(get_client_ip_address(HttpRequest()), "127.0.0.1")
+
+    @override_settings(AXES_CLIENT_IP_CALLABLE=True)
+    def test_get_client_ip_address_not_callable(self):
+        with self.assertRaises(TypeError):
+            get_client_ip_address(HttpRequest())
+
+    @override_settings(AXES_CLIENT_IP_CALLABLE=lambda: None)  # pragma: no cover
+    def test_get_client_ip_address_invalid_callable_too_few_arguments(self):
+        with self.assertRaises(TypeError):
+            get_client_ip_address(HttpRequest())
+
+    @override_settings(
+        AXES_CLIENT_IP_CALLABLE=lambda request, extra: None
+    )  # pragma: no cover
+    def test_get_client_ip_address_invalid_callable_too_many_arguments(self):
+        with self.assertRaises(TypeError):
+            get_client_ip_address(HttpRequest())
+
+    def test_get_client_ip_address_with_ipware(self):
+        request = HttpRequest()
+        request.META["REMOTE_ADDR"] = "127.0.0.2"
+        self.assertEqual(get_client_ip_address(request, use_ipware=True), "127.0.0.2")
+
+    def test_get_client_ip_address_without_ipware(self):
+        request = HttpRequest()
+        request.META["REMOTE_ADDR"] = "127.0.0.3"
+        self.assertEqual(get_client_ip_address(request, use_ipware=False), "127.0.0.3")
+
+
 class IPWhitelistTestCase(AxesTestCase):
     def setUp(self):
         self.request = HttpRequest()
@@ -730,12 +987,12 @@ class AxesLockoutTestCase(AxesTestCase):
 
     def test_get_lockout_response(self):
         response = get_lockout_response(self.request, self.credentials)
-        self.assertEqual(403, response.status_code)
+        self.assertEqual(429, response.status_code)
 
-    @override_settings(AXES_HTTP_RESPONSE_CODE=429)
+    @override_settings(AXES_HTTP_RESPONSE_CODE=403)
     def test_get_lockout_response_with_custom_http_response_code(self):
         response = get_lockout_response(self.request, self.credentials)
-        self.assertEqual(429, response.status_code)
+        self.assertEqual(403, response.status_code)
 
     @override_settings(AXES_LOCKOUT_CALLABLE=mock_get_lockout_response)
     def test_get_lockout_response_override_callable(self):
diff --git a/tests/test_logging.py b/tests/test_logging.py
index 35ae52e..bbb6b3e 100644
--- a/tests/test_logging.py
+++ b/tests/test_logging.py
@@ -34,25 +34,26 @@ class AppsTestCase(AxesTestCase):
         AppConfig.initialize()
         self.assertFalse(log.info.called)
 
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     def test_axes_config_log_user_only(self, log):
         AppConfig.initialize()
-        log.info.assert_called_with(_BEGIN, _VERSION, "blocking by username only")
+        log.info.assert_called_with(_BEGIN, _VERSION, "blocking by username")
 
-    @override_settings(AXES_ONLY_USER_FAILURES=False)
     def test_axes_config_log_ip_only(self, log):
         AppConfig.initialize()
-        log.info.assert_called_with(_BEGIN, _VERSION, "blocking by IP only")
+        log.info.assert_called_with(_BEGIN, _VERSION, "blocking by ip_address")
 
-    @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
     def test_axes_config_log_user_ip(self, log):
         AppConfig.initialize()
-        log.info.assert_called_with(_BEGIN, _VERSION, "blocking by combination of username and IP")
+        log.info.assert_called_with(
+            _BEGIN, _VERSION, "blocking by combination of username and ip_address"
+        )
 
-    @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
     def test_axes_config_log_user_or_ip(self, log):
         AppConfig.initialize()
-        log.info.assert_called_with(_BEGIN, _VERSION, "blocking by username or IP")
+        log.info.assert_called_with(_BEGIN, _VERSION, "blocking by username or ip_address")
 
 
 class AccessLogTestCase(AxesTestCase):
@@ -64,11 +65,12 @@ class AccessLogTestCase(AxesTestCase):
         self.login(is_valid_username=True, is_valid_password=True)
         self.assertIsNone(AccessLog.objects.latest("id").logout_time)
 
-        response = self.client.get(reverse("admin:logout"))
+        response = self.client.post(reverse("admin:logout"))
         self.assertContains(response, "Logged out")
 
         self.assertIsNotNone(AccessLog.objects.latest("id").logout_time)
 
+    @override_settings(DATA_UPLOAD_MAX_NUMBER_FIELDS=1500)
     def test_log_data_truncated(self):
         """
         Test that get_query_str properly truncates data to the max_length (default 1024).
@@ -84,7 +86,7 @@ class AccessLogTestCase(AxesTestCase):
         AccessLog.objects.all().delete()
 
         response = self.login(is_valid_username=True, is_valid_password=True)
-        response = self.client.get(reverse("admin:logout"))
+        response = self.client.post(reverse("admin:logout"))
 
         self.assertEqual(AccessLog.objects.all().count(), 0)
         self.assertContains(response, "Logged out", html=True)
@@ -107,7 +109,7 @@ class AccessLogTestCase(AxesTestCase):
         AccessLog.objects.all().delete()
 
         response = self.login(is_valid_username=True, is_valid_password=True)
-        response = self.client.get(reverse("admin:logout"))
+        response = self.client.post(reverse("admin:logout"))
 
         self.assertEqual(AccessLog.objects.count(), 0)
         self.assertContains(response, "Logged out", html=True)
diff --git a/tests/test_login.py b/tests/test_login.py
index 07d5a45..1caf0ad 100644
--- a/tests/test_login.py
+++ b/tests/test_login.py
@@ -84,9 +84,9 @@ class DatabaseLoginTestCase(AxesTestCase):
     LOGIN_FORM_KEY = '<input type="submit" value="Log in" />'
     ATTEMPT_NOT_BLOCKED = 200
     ALLOWED = 302
-    BLOCKED = 403
+    BLOCKED = 429
 
-    def _login(self, username, password, ip_addr="127.0.0.1", **kwargs):
+    def _login(self, username, password, ip_addr="127.0.0.1", user_agent="test-browser", **kwargs):
         """
         Login a user and get the response.
 
@@ -101,13 +101,13 @@ class DatabaseLoginTestCase(AxesTestCase):
             reverse("admin:login"),
             post_data,
             REMOTE_ADDR=ip_addr,
-            HTTP_USER_AGENT="test-browser",
+            HTTP_USER_AGENT=user_agent,
         )
 
-    def _lockout_user_from_ip(self, username, ip_addr):
+    def _lockout_user_from_ip(self, username, ip_addr, user_agent="test-browser"):
         for _ in range(settings.AXES_FAILURE_LIMIT):
             response = self._login(
-                username=username, password=self.WRONG_PASSWORD, ip_addr=ip_addr
+                username=username, password=self.WRONG_PASSWORD, ip_addr=ip_addr, user_agent=user_agent,
             )
         return response
 
@@ -182,10 +182,11 @@ class DatabaseLoginTestCase(AxesTestCase):
         self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
         self.assertTrue(self.attempt_count())
 
-    @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
     def test_lockout_by_combination_user_and_ip(self):
         """
-        Test login failure when AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP is True.
+        Test login failure when lockout parameters is combination
+        of username and ip_address.
         """
 
         # test until one try before the limit
@@ -197,12 +198,12 @@ class DatabaseLoginTestCase(AxesTestCase):
         # So, we shouldn't have gotten a lock-out yet.
         # But we should get one now
         response = self.login(is_valid_username=True, is_valid_password=False)
-        self.assertContains(response, self.LOCKED_MESSAGE, status_code=403)
+        self.assertContains(response, self.LOCKED_MESSAGE, status_code=429)
 
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     def test_lockout_by_only_user_failures(self):
         """
-        Test login failure when AXES_ONLY_USER_FAILURES is True.
+        Test login failure when lockout parameter is username.
         """
 
         # test until one try before the limit
@@ -238,6 +239,139 @@ class DatabaseLoginTestCase(AxesTestCase):
             response, self.LOGIN_FORM_KEY, status_code=self.ALLOWED, html=True
         )
 
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["user_agent"])
+    def test_lockout_by_user_agent_only(self):
+        """
+        Test login failure when lockout parameter is only user_agent
+        """
+        # User is locked out with "test-browser" user agent.
+        self._lockout_user_from_ip(username="username", ip_addr=self.IP_1, user_agent="test-browser")
+
+        # Test he is locked:
+        response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
+        self.assertEqual(response.status_code, self.BLOCKED)
+
+        # Test he is locked with another username:
+        response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
+        self.assertEqual(response.status_code, self.BLOCKED)
+
+        # Test he is locked with another ip:
+        response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
+        self.assertEqual(response.status_code, self.BLOCKED)
+
+        # Test with another user agent:
+        response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser-2")
+        self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
+
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["ip_address", "username", "user_agent"])
+    def test_lockout_by_all_parameters(self):
+        # User is locked out with "test-browser" user agent.
+        self._lockout_user_from_ip(username="username", ip_addr=self.IP_1, user_agent="test-browser")
+
+        # Test he is locked:
+        response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
+        self.assertEqual(response.status_code, self.BLOCKED)
+
+        # Test he is locked by username:
+        response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser2")
+        self.assertEqual(response.status_code, self.BLOCKED)
+
+        # Test he is locked by ip:
+        response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser2")
+        self.assertEqual(response.status_code, self.BLOCKED)
+
+        # Test he is locked by user_agent:
+        response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
+        self.assertEqual(response.status_code, self.BLOCKED)
+       
+        # Test he is allowed to login with different username, ip and user_agent
+        response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser2")
+        self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
+
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "username", "user_agent"]])
+    def test_lockout_by_combination_of_all_parameters(self):
+        # User is locked out with "test-browser" user agent.
+        self._lockout_user_from_ip(username="username", ip_addr=self.IP_1, user_agent="test-browser")
+
+        # Test he is locked:
+        response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
+        self.assertEqual(response.status_code, self.BLOCKED)
+
+        # Test he is allowed to login with different username:
+        response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
+        self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
+
+        # Test he is allowed to login with different IP:
+        response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
+        self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
+
+        # Test he is allowed to login with different user_agent:
+        response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser2")
+        self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
+       
+        # Test he is allowed to login with different username, ip and user_agent
+        response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser2")
+        self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
+
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["ip_address", ["username", "user_agent"]])
+    def test_lockout_by_ip_or_username_and_user_agent(self):
+        # User is locked out with "test-browser" user agent.
+        self._lockout_user_from_ip(username="username", ip_addr=self.IP_1, user_agent="test-browser")
+
+        # Test he is locked:
+        response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
+        self.assertEqual(response.status_code, self.BLOCKED)
+
+        # Test he is locked by ip:
+        response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser2")
+        self.assertEqual(response.status_code, self.BLOCKED)
+
+        # Test he is locked by username and user_agent:
+        response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
+        self.assertEqual(response.status_code, self.BLOCKED)
+
+        # Test he is allowed to login with different username and ip
+        response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
+        self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
+
+        # Test he is allowed to login with different user_agent and ip
+        response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser2")
+        self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
+
+        # Test he is allowed to login with different username, ip and user_agent
+        response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser2")
+        self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
+
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "user_agent"], ["username", "user_agent"]])
+    def test_lockout_by_ip_and_user_agent_or_username_and_user_agent(self):
+        # User is locked out with "test-browser" user agent.
+        self._lockout_user_from_ip(username="username", ip_addr=self.IP_1, user_agent="test-browser")
+
+        # Test he is locked:
+        response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
+        self.assertEqual(response.status_code, self.BLOCKED)
+
+        # Test he is locked by ip and user_agent:
+        response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
+        self.assertEqual(response.status_code, self.BLOCKED)
+
+        # Test he is locked by username and user_agent:
+        response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
+        self.assertEqual(response.status_code, self.BLOCKED)
+
+        # Test he is allowed to login with different username and ip
+        response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
+        self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
+
+        # Test he is allowed to login with different user_agent
+        response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser2")
+        self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
+
+        # Test he is allowed to login with different username, ip and user_agent
+        response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser2")
+        self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
+
+
     # Test for true and false positives when blocking by IP *OR* user (default)
     # Cache disabled. Default settings.
     def test_lockout_by_ip_blocks_when_same_user_same_ip_without_cache(self):
@@ -274,7 +408,7 @@ class DatabaseLoginTestCase(AxesTestCase):
 
     # Test for true and false positives when blocking by user only.
     # Cache disabled. When AXES_ONLY_USER_FAILURES = True
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     def test_lockout_by_user_blocks_when_same_user_same_ip_without_cache(self):
         # User 1 is locked out from IP 1.
         self._lockout_user1_from_ip1()
@@ -283,7 +417,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
         self.assertEqual(response.status_code, self.BLOCKED)
 
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     def test_lockout_by_user_blocks_when_same_user_diff_ip_without_cache(self):
         # User 1 is locked out from IP 1.
         self._lockout_user1_from_ip1()
@@ -292,7 +426,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
         self.assertEqual(response.status_code, self.BLOCKED)
 
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     def test_lockout_by_user_allows_when_diff_user_same_ip_without_cache(self):
         # User 1 is locked out from IP 1.
         self._lockout_user1_from_ip1()
@@ -301,7 +435,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
         self.assertEqual(response.status_code, self.ALLOWED)
 
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     def test_lockout_by_user_allows_when_diff_user_diff_ip_without_cache(self):
         # User 1 is locked out from IP 1.
         self._lockout_user1_from_ip1()
@@ -310,7 +444,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
         self.assertEqual(response.status_code, self.ALLOWED)
 
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     def test_lockout_by_user_with_empty_username_allows_other_users_without_cache(self):
         # User with empty username is locked out from IP 1.
         self._lockout_user_from_ip(username="", ip_addr=self.IP_1)
@@ -321,7 +455,7 @@ class DatabaseLoginTestCase(AxesTestCase):
 
     # Test for true and false positives when blocking by user and IP together.
     # Cache disabled. When LOCK_OUT_BY_COMBINATION_USER_AND_IP = True
-    @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
     def test_lockout_by_user_and_ip_blocks_when_same_user_same_ip_without_cache(self):
         # User 1 is locked out from IP 1.
         self._lockout_user1_from_ip1()
@@ -330,7 +464,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
         self.assertEqual(response.status_code, self.BLOCKED)
 
-    @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
     def test_lockout_by_user_and_ip_allows_when_same_user_diff_ip_without_cache(self):
         # User 1 is locked out from IP 1.
         self._lockout_user1_from_ip1()
@@ -339,7 +473,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
         self.assertEqual(response.status_code, self.ALLOWED)
 
-    @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
     def test_lockout_by_user_and_ip_allows_when_diff_user_same_ip_without_cache(self):
         # User 1 is locked out from IP 1.
         self._lockout_user1_from_ip1()
@@ -348,7 +482,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
         self.assertEqual(response.status_code, self.ALLOWED)
 
-    @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
     def test_lockout_by_user_and_ip_allows_when_diff_user_diff_ip_without_cache(self):
         # User 1 is locked out from IP 1.
         self._lockout_user1_from_ip1()
@@ -357,7 +491,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
         self.assertEqual(response.status_code, self.ALLOWED)
 
-    @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
     def test_lockout_by_user_and_ip_with_empty_username_allows_other_users_without_cache(
         self,
     ):
@@ -368,6 +502,19 @@ 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_LOCKOUT_PARAMETERS=[["ip_address", "user_agent"]])
+    def test_lockout_by_user_still_allows_login_with_differnet_user_agent(self):
+        # User with empty username is locked out with "test-browser" user agent.
+        self._lockout_user_from_ip(username="username", ip_addr=self.IP_1, user_agent="test-browser")
+
+        # Test he is locked:
+        response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
+        self.assertEqual(response.status_code, self.BLOCKED)
+
+        # Test with another user agent:
+        response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser-2")
+        self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
+
     # Test for true and false positives when blocking by IP *OR* user (default)
     # With cache enabled. Default criteria.
     def test_lockout_by_ip_blocks_when_same_user_same_ip_using_cache(self):
@@ -402,7 +549,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
         self.assertEqual(response.status_code, self.ALLOWED)
 
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     def test_lockout_by_user_with_empty_username_allows_other_users_using_cache(self):
         # User with empty username is locked out from IP 1.
         self._lockout_user_from_ip(username="", ip_addr=self.IP_1)
@@ -413,7 +560,7 @@ class DatabaseLoginTestCase(AxesTestCase):
 
     # Test for true and false positives when blocking by user only.
     # With cache enabled. When AXES_ONLY_USER_FAILURES = True
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     def test_lockout_by_user_blocks_when_same_user_same_ip_using_cache(self):
         # User 1 is locked out from IP 1.
         self._lockout_user1_from_ip1()
@@ -422,7 +569,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
         self.assertEqual(response.status_code, self.BLOCKED)
 
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     def test_lockout_by_user_blocks_when_same_user_diff_ip_using_cache(self):
         # User 1 is locked out from IP 1.
         self._lockout_user1_from_ip1()
@@ -431,7 +578,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
         self.assertEqual(response.status_code, self.BLOCKED)
 
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     def test_lockout_by_user_allows_when_diff_user_same_ip_using_cache(self):
         # User 1 is locked out from IP 1.
         self._lockout_user1_from_ip1()
@@ -440,7 +587,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
         self.assertEqual(response.status_code, self.ALLOWED)
 
-    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
     def test_lockout_by_user_allows_when_diff_user_diff_ip_using_cache(self):
         # User 1 is locked out from IP 1.
         self._lockout_user1_from_ip1()
@@ -451,7 +598,7 @@ class DatabaseLoginTestCase(AxesTestCase):
 
     # Test for true and false positives when blocking by user and IP together.
     # With cache enabled. When LOCK_OUT_BY_COMBINATION_USER_AND_IP = True
-    @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
     def test_lockout_by_user_and_ip_blocks_when_same_user_same_ip_using_cache(self):
         # User 1 is locked out from IP 1.
         self._lockout_user1_from_ip1()
@@ -460,7 +607,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
         self.assertEqual(response.status_code, self.BLOCKED)
 
-    @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
     def test_lockout_by_user_and_ip_allows_when_same_user_diff_ip_using_cache(self):
         # User 1 is locked out from IP 1.
         self._lockout_user1_from_ip1()
@@ -469,7 +616,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
         self.assertEqual(response.status_code, self.ALLOWED)
 
-    @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
     def test_lockout_by_user_and_ip_allows_when_diff_user_same_ip_using_cache(self):
         # User 1 is locked out from IP 1.
         self._lockout_user1_from_ip1()
@@ -478,7 +625,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
         self.assertEqual(response.status_code, self.ALLOWED)
 
-    @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
     def test_lockout_by_user_and_ip_allows_when_diff_user_diff_ip_using_cache(self):
         # User 1 is locked out from IP 1.
         self._lockout_user1_from_ip1()
@@ -488,7 +635,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         self.assertEqual(response.status_code, self.ALLOWED)
 
     @override_settings(
-        AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True, AXES_FAILURE_LIMIT=2
+        AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]], AXES_FAILURE_LIMIT=2
     )
     def test_lockout_by_user_and_ip_allows_when_diff_user_same_ip_using_cache_multiple_attempts(
         self,
@@ -517,7 +664,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
         self.assertEqual(response.status_code, self.ALLOWED)
 
-    @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
     def test_lockout_by_user_and_ip_with_empty_username_allows_other_users_using_cache(
         self,
     ):
@@ -530,7 +677,7 @@ class DatabaseLoginTestCase(AxesTestCase):
 
     # Test for true and false positives when blocking by user or IP together.
     # With cache enabled. When AXES_LOCK_OUT_BY_USER_OR_IP = True
-    @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
     def test_lockout_by_user_or_ip_blocks_when_same_user_same_ip_using_cache(self):
         # User 1 is locked out from IP 1.
         self._lockout_user1_from_ip1()
@@ -539,7 +686,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
         self.assertEqual(response.status_code, self.BLOCKED)
 
-    @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
     def test_lockout_by_user_or_ip_allows_when_same_user_diff_ip_using_cache(self):
         # User 1 is locked out from IP 1.
         self._lockout_user1_from_ip1()
@@ -548,7 +695,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
         self.assertEqual(response.status_code, self.BLOCKED)
 
-    @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
     def test_lockout_by_user_or_ip_allows_when_diff_user_same_ip_using_cache(self):
         # User 1 is locked out from IP 1.
         self._lockout_user1_from_ip1()
@@ -557,7 +704,9 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
         self.assertEqual(response.status_code, self.BLOCKED)
 
-    @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True, AXES_FAILURE_LIMIT=3)
+    @override_settings(
+        AXES_LOCKOUT_PARAMETERS=["username", "ip_address"], AXES_FAILURE_LIMIT=3
+    )
     def test_lockout_by_user_or_ip_allows_when_diff_user_same_ip_using_cache_multiple_attempts(
         self,
     ):
@@ -587,7 +736,9 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_3, self.WRONG_PASSWORD, ip_addr=self.IP_1)
         self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
 
-    @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True, AXES_FAILURE_LIMIT=3)
+    @override_settings(
+        AXES_LOCKOUT_PARAMETERS=["username", "ip_address"], AXES_FAILURE_LIMIT=3
+    )
     def test_lockout_by_user_or_ip_allows_when_diff_user_same_ip_using_cache_multiple_failed_attempts(
         self,
     ):
@@ -612,7 +763,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
         self.assertEqual(response.status_code, self.ALLOWED)
 
-    @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
     def test_lockout_by_user_or_ip_allows_when_diff_user_diff_ip_using_cache(self):
         # User 1 is locked out from IP 1.
         self._lockout_user1_from_ip1()
@@ -621,7 +772,7 @@ class DatabaseLoginTestCase(AxesTestCase):
         response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
         self.assertEqual(response.status_code, self.ALLOWED)
 
-    @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
+    @override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
     def test_lockout_by_user_or_ip_with_empty_username_allows_other_users_using_cache(
         self,
     ):
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
index 8b098d1..cf88927 100644
--- a/tests/test_middleware.py
+++ b/tests/test_middleware.py
@@ -12,7 +12,7 @@ def get_username(request, credentials: dict) -> str:
 
 class MiddlewareTestCase(AxesTestCase):
     STATUS_SUCCESS = 200
-    STATUS_LOCKOUT = 403
+    STATUS_LOCKOUT = 429
 
     def setUp(self):
         self.request = HttpRequest()

More details

Full run details