Repository: jazzband/django-axes (python)

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 !

Pull request description:
Add `AXES_CLIENT_IP_CALLABLE` setting
Resolves #1021 

Patch:
diff --git a/axes/conf.py b/axes/conf.py
index 745024f..c3999e5 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)
 
@@ -52,6 +51,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)
 
diff --git a/axes/helpers.py b/axes/helpers.py
index e40a2b3..457354d 100644
--- a/axes/helpers.py
+++ b/axes/helpers.py
@@ -6,9 +6,9 @@ from typing import Callable, Optional, Type, Union
 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,
diff --git a/docs/4_configuration.rst b/docs/4_configuration.rst
index 6228d9e..d9071a6 100644
--- a/docs/4_configuration.rst
+++ b/docs/4_configuration.rst
@@ -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.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
diff --git a/docs/5_customization.rst b/docs/5_customization.rst
index 44b3836..47f2fd5 100644
--- a/docs/5_customization.rst
+++ b/docs/5_customization.rst
@@ -173,3 +173,18 @@ An example of usage could be e.g. a custom view for processing lockouts.
 ``settings.py``::
 
     AXES_LOCKOUT_CALLABLE = "example.views.lockout"
+
+
+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_LOCKOUT_CALLABLE = "example.utils.get_client_ip"


Test patch:
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index 1da372d..fc4b5a3 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_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 @@ 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
@@ -341,7 +342,11 @@ class ClientParametersTestCase(AxesTestCase):
         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()