Repository: jazzband/django-axes
Pull request #1044 (2023-04-26 15:41:21)

Make ipware an optional dependency
Relates to #1038 

codecov[bot]: ## [Codecov](https://codecov.io/gh/jazzband/django-axes/pull/1044?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband) Report
> Merging [#1044](https://codecov.io/gh/jazzband/django-axes/pull/1044?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband) (5f69658) into [master](https://codecov.io/gh/jazzband/django-axes/commit/f491f3dea86d0ef007dcc934a750db1819e82e95?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband) (f491f3d) will **decrease** coverage by `0.45%`.
> The diff coverage is `80.00%`.

```diff
@@            Coverage Diff             @@
##           master    #1044      +/-   ##
==========================================
- Coverage   96.20%   95.76%   -0.45%     
==========================================
  Files          34       34              
  Lines        1055     1062       +7     
  Branches      188      146      -42     
==========================================
+ Hits         1015     1017       +2     
- Misses         34       39       +5     
  Partials        6        6              
```


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

... and [1 file with indirect coverage changes](https://codecov.io/gh/jazzband/django-axes/pull/1044/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband)

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jazzband)


Patch:
diff --git a/axes/checks.py b/axes/checks.py
index 711bcf8..8746ea6 100644
--- a/axes/checks.py
+++ b/axes/checks.py
@@ -122,6 +122,12 @@ 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",
     ]
 
     for deprecated_setting in deprecated_settings:
diff --git a/axes/conf.py b/axes/conf.py
index eea9b46..9dab628 100644
--- a/axes/conf.py
+++ b/axes/conf.py
@@ -108,24 +108,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", "*")
 
@@ -147,3 +129,44 @@ settings.AXES_HTTP_RESPONSE_CODE = getattr(settings, "AXES_HTTP_RESPONSE_CODE",
 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/helpers.py b/axes/helpers.py
index 778d582..331d1a6 100644
--- a/axes/helpers.py
+++ b/axes/helpers.py
@@ -5,7 +5,6 @@ from string import Template
 from typing import Callable, Optional, Type, Union
 from urllib.parse import urlencode
 
-import ipware.ip
 from django.core.cache import BaseCache, caches
 from django.http import HttpRequest, HttpResponse, JsonResponse, QueryDict
 from django.shortcuts import redirect, render
@@ -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,20 +154,24 @@ 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 order of preference for address resolution is as follows:
 
     1. If configured, use ``AXES_CLIENT_IP_CALLABLE``, and supply ``request`` as argument
-    2. Use django-ipware package (parameters can be configured in the Axes package)
+    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
     """
 
     if settings.AXES_CLIENT_IP_CALLABLE:
-        log.debug("Using settings.AXES_CLIENT_IP_CALLABLE to get username")
+        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
@@ -173,15 +183,26 @@ def get_client_ip_address(request: HttpRequest) -> str:
             "settings.AXES_CLIENT_IP_CALLABLE needs to be a string, callable, or None."
         )
 
-    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,
-    )
+    # 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
 
-    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:
diff --git a/docs/2_installation.rst b/docs/2_installation.rst
index e001768..27d1f45 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.
 
diff --git a/docs/4_configuration.rst b/docs/4_configuration.rst
index ead231e..13091a4 100644
--- a/docs/4_configuration.rst
+++ b/docs/4_configuration.rst
@@ -103,8 +103,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', )``
 
@@ -114,7 +114,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/setup.py b/setup.py
index c7a6d1f..aca2304 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=[


Test patch:
diff --git a/requirements-test.txt b/requirements-test.txt
index 7c28881..a7a440f 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -1,4 +1,5 @@
 -e .
+django-ipware>=3
 coverage==7.2.3
 pytest==7.3.1
 pytest-cov==4.0.0
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index 15e0746..2677a17 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -614,6 +614,16 @@ class ClientIpAddressTestCase(AxesTestCase):
         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):