Repository: jazzband/django-axes

Issue:
Possibility to override get_client_ip method
Hi, 

First of all, thank you for this package and your work !

My problem is that I cannot find a proper configuration for _django_ipware_ that fits my case. 

My server is behind a load balancer and a reverse proxy.
The load balancer is configured in append mode and so appends the client ip address to the `X-Forwarded-For` header existing values or create the header with the client ip address if it does not exist in the request. 
The reverse proxy does nothing in particular with this header, just passes it to the server.

So, I get something like this on the server: `X-Forwarded-For = <original_data_from_client> -- ...   -- <client_ip_set_by_load_balancer>`. 

I configure _django-axes_ with `AXES_PROXY_ORDER = 'right-most'` as I want _django-axes_ to get the last ip address contained in the header, even if other values are invalid.

_django_ipware_ does not enable that. It skips the whole header if the first or the last ip address is not a valid one.
https://github.com/un33k/django-ipware/blob/master/ipware/ip.py#L27
https://github.com/un33k/django-ipware/blob/930f3293eb11b2bfc221959434e8501c165a2b61/ipware/utils.py#L94 

If I try to spoof my ip address using a valid ip address, all works good, _django_axes_ gets the `most-right` ones but if I set an invalid ip address in the header, the whole header is skipped.

I don't know what would be the best way to fix that, if I should open an issue in _django_ipware_  or update _django-axes_ to add the possibility to define a custom  and simple `get_client_ip` method in this case ?

Thank you !


Discussion:
Hey @PaulM5406!

Sure, it should be easy to implement a customization that enables overriding the `get_client_ip` implementation by supplying a different callable or a path to calling a different function, for example, similarly to `AXES_USERNAME_CALLABLE` etc:

https://django-axes.readthedocs.io/en/latest/4_configuration.html#configuring-project-settings

The current implementation for fetching the IP is:

https://github.com/jazzband/django-axes/blob/master/axes/helpers.py#L151

The same `helpers.py` module has different examples of using a callable for fetching different parameters as well.

Would you be interested in opening a PR for this? The feature should include at least the implementation and unit tests for it, and preferably documentation as well.

Resolving patch:
diff --git a/axes/conf.py b/axes/conf.py
--- 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)
 
@@ -52,6 +51,9 @@
 # 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)
 
diff --git a/axes/helpers.py b/axes/helpers.py
--- a/axes/helpers.py
+++ b/axes/helpers.py
@@ -6,9 +6,9 @@
 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
@@ -152,10 +152,27 @@ def get_client_ip_address(request: HttpRequest) -> 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. Use django-ipware package (parameters can be configured in the Axes package)
+
+    :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")
+
+        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."
+        )
+
     client_ip_address, _ = ipware.ip.get_client_ip(
         request,
         proxy_order=settings.AXES_PROXY_ORDER,


Test patch:
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -3,16 +3,18 @@
 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_str,
-    get_client_username,
     get_client_cache_key,
+    get_client_ip_address,
     get_client_parameters,
+    get_client_str,
+    get_client_username,
     get_cool_off,
     get_cool_off_iso8601,
     get_lockout_response,
@@ -23,7 +25,6 @@
     is_ip_address_in_whitelist,
     is_user_attempt_whitelisted,
     toggleable,
-    cleanse_parameters,
 )
 from axes.models import AccessAttempt
 from tests.base import AxesTestCase
@@ -341,7 +342,11 @@ def test_get_filter_kwargs_user_ip_agent(self):
         self.assertEqual(
             get_client_parameters(self.username, self.ip_address, self.user_agent),
             [
-                {"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,
+                },
             ],
         )
 
@@ -553,6 +558,43 @@ 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())
+
+
 class IPWhitelistTestCase(AxesTestCase):
     def setUp(self):
         self.request = HttpRequest()