Repository: jazzband/django-axes

Issue:
Would HTTP 429s be more appropriate?
I recently had a penetration tester question whether or not django-axes was working because it returned the same 403 code as an invalid password attempt.

I did verify it was working correctly, but I thought I would raise the issue for discussion on whether there would be merit to using a 429 status code instead of a 403.

The 429 is arguably more semantically correct than the 403, and would make it easier to distinguish a rejected password vs a blocked attempt via HTTP headers alone.

I look forward to your thoughts. If you feel this is a worthwhile change I can prepare a pull request.


Discussion:
HTTP 429 might be semantically more correct:

https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429

The response could also in principle include the standardized `Retry-After` header to indicate how long the lockout period is.

This would change the current behaviour and be a good candidate for the version 6 release of Axes?
@anthonyryan1 this has now been made possible by #770 but I'm wondering if the default could still perhaps be updated from 403 to 429, and would this warrant a major release 6.x as part of the update?

Resolving patch:
diff --git a/axes/conf.py b/axes/conf.py
--- a/axes/conf.py
+++ b/axes/conf.py
@@ -141,7 +141,7 @@
 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(


Test patch:
diff --git a/tests/base.py b/tests/base.py
--- 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_decorators.py b/tests/test_decorators.py
--- a/tests/test_decorators.py
+++ b/tests/test_decorators.py
@@ -8,7 +8,7 @@
 
 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_helpers.py b/tests/test_helpers.py
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -791,12 +791,12 @@ def setUp(self):
 
     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_login.py b/tests/test_login.py
--- a/tests/test_login.py
+++ b/tests/test_login.py
@@ -84,7 +84,7 @@ 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", user_agent="test-browser", **kwargs):
         """
@@ -197,7 +197,7 @@ def test_lockout_by_combination_user_and_ip(self):
         # 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)
     def test_lockout_by_only_user_failures(self):
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
--- 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()