Repository: jazzband/django-axes

Issue:
django-graphql-jwt integration
Hi everyone

I'm searchin how to integrate Axes with django_graphql_jwt.
Errors inside mutations raises exceptions and the library catch it and response to user with right graphql format, but axes lockout response redirect and blocks the JWT process.

Some ideas?


Discussion:
You can add a new feature to the `get_lockout_response` that can be used for GraphQL and open a PR for it:

https://github.com/jazzband/django-axes/blob/cd75ef45105b12c756dac29e1b171766a5fe9293/axes/helpers.py#L280

A custom function for returning a lockout response could be implemented similar to how cool off and failure limit are currently done. 

You could add a configuration key such as `AXES_LOCKOUT_RESPONSE_CALLABLE` that could be configured to a str pointing to a callable or a callable itself that takes request and credentials parameters. 

For example:

```
AXES_LOCKOUT_RESPONSE_CALLABLE = 'package.module.get_graphql_response`

# or

AXES_LOCKOUT_RESPONSE_CALLABLE = lambda request, credentials: return None
```

Then you can add a handler for the configuration value into the existing function and be happy.

You can inspect the current codebase in `axes/conf.py` and `axes/helpers.py` to refer how the flags are implemented now.

If you add the configurations, please add unit tests for them as well. The unit tests can have a simple and minimal implementations similar to the current tests.

Resolving patch:
diff --git a/axes/conf.py b/axes/conf.py
--- a/axes/conf.py
+++ b/axes/conf.py
@@ -41,6 +41,9 @@ class AxesAppConf(AppConf):
     # determine if given user should be always allowed to attempt authentication
     WHITELIST_CALLABLE = None
 
+    # return custom lockout response if configured
+    LOCKOUT_CALLABLE = None
+
     # reset the number of failed attempts after one successful attempt
     RESET_ON_SUCCESS = False
 
diff --git a/axes/helpers.py b/axes/helpers.py
--- a/axes/helpers.py
+++ b/axes/helpers.py
@@ -308,6 +308,15 @@ def get_lockout_message() -> str:
 
 
 def get_lockout_response(request, credentials: dict = None) -> HttpResponse:
+    if settings.AXES_LOCKOUT_CALLABLE:
+        if callable(settings.AXES_LOCKOUT_CALLABLE):
+            return settings.AXES_LOCKOUT_CALLABLE(request, credentials)
+        if isinstance(settings.AXES_LOCKOUT_CALLABLE, str):
+            return import_string(settings.AXES_LOCKOUT_CALLABLE)(request, credentials)
+        raise TypeError(
+            "settings.AXES_LOCKOUT_CALLABLE needs to be a string, callable, or None."
+        )
+
     status = 403
     context = {
         "failure_limit": get_failure_limit(request, credentials),


Test patch:
diff --git a/axes/tests/test_helpers.py b/axes/tests/test_helpers.py
--- a/axes/tests/test_helpers.py
+++ b/axes/tests/test_helpers.py
@@ -1,22 +1,18 @@
 from datetime import timedelta
 
 from django.contrib.auth import get_user_model
-from django.http import HttpRequest
+from django.http import HttpRequest, HttpResponse
 from django.test import override_settings
 
-from axes.helpers import get_cool_off, is_user_attempt_whitelisted
+from axes.helpers import get_cool_off, get_lockout_response, is_user_attempt_whitelisted
 from axes.tests.base import AxesTestCase
 
 
-def get_cool_off_str():
+def mock_get_cool_off_str():
     return timedelta(seconds=30)
 
 
-def is_whitelisted(request, credentials):
-    return True
-
-
-class AxesHelpersTestCase(AxesTestCase):
+class AxesCoolOffTestCase(AxesTestCase):
     @override_settings(AXES_COOLOFF_TIME=None)
     def test_get_cool_off_none(self):
         self.assertIsNone(get_cool_off())
@@ -29,12 +25,18 @@ def test_get_cool_off_int(self):
     def test_get_cool_off_callable(self):
         self.assertEqual(get_cool_off(), timedelta(seconds=30))
 
-    @override_settings(AXES_COOLOFF_TIME="axes.tests.test_helpers.get_cool_off_str")
-    def test_get_cool_off_str(self):
+    @override_settings(
+        AXES_COOLOFF_TIME="axes.tests.test_helpers.mock_get_cool_off_str"
+    )
+    def test_get_cool_off_path(self):
         self.assertEqual(get_cool_off(), timedelta(seconds=30))
 
 
-class UserWhitelistTestCase(AxesTestCase):
+def mock_is_whitelisted(request, credentials):
+    return True
+
+
+class AxesWhitelistTestCase(AxesTestCase):
     def setUp(self):
         self.user_model = get_user_model()
         self.user = self.user_model.objects.create(username="jane.doe")
@@ -44,11 +46,13 @@ def setUp(self):
     def test_is_whitelisted(self):
         self.assertFalse(is_user_attempt_whitelisted(self.request, self.credentials))
 
-    @override_settings(AXES_WHITELIST_CALLABLE=is_whitelisted)
-    def test_is_whitelisted_override(self):
+    @override_settings(AXES_WHITELIST_CALLABLE=mock_is_whitelisted)
+    def test_is_whitelisted_override_callable(self):
         self.assertTrue(is_user_attempt_whitelisted(self.request, self.credentials))
 
-    @override_settings(AXES_WHITELIST_CALLABLE="axes.tests.test_helpers.is_whitelisted")
+    @override_settings(
+        AXES_WHITELIST_CALLABLE="axes.tests.test_helpers.mock_is_whitelisted"
+    )
     def test_is_whitelisted_override_path(self):
         self.assertTrue(is_user_attempt_whitelisted(self.request, self.credentials))
 
@@ -56,3 +60,34 @@ def test_is_whitelisted_override_path(self):
     def test_is_whitelisted_override_invalid(self):
         with self.assertRaises(TypeError):
             is_user_attempt_whitelisted(self.request, self.credentials)
+
+
+def mock_get_lockout_response(request, credentials):
+    return HttpResponse(status=400)
+
+
+class AxesLockoutTestCase(AxesTestCase):
+    def setUp(self):
+        self.request = HttpRequest()
+        self.credentials = dict()
+
+    def test_get_lockout_response(self):
+        response = get_lockout_response(self.request, self.credentials)
+        self.assertEqual(403, response.status_code)
+
+    @override_settings(AXES_LOCKOUT_CALLABLE=mock_get_lockout_response)
+    def test_get_lockout_response_override_callable(self):
+        response = get_lockout_response(self.request, self.credentials)
+        self.assertEqual(400, response.status_code)
+
+    @override_settings(
+        AXES_LOCKOUT_CALLABLE="axes.tests.test_helpers.mock_get_lockout_response"
+    )
+    def test_get_lockout_response_override_path(self):
+        response = get_lockout_response(self.request, self.credentials)
+        self.assertEqual(400, response.status_code)
+
+    @override_settings(AXES_LOCKOUT_CALLABLE=42)
+    def test_get_lockout_response_override_invalid(self):
+        with self.assertRaises(TypeError):
+            get_lockout_response(self.request, self.credentials)