Repository: jazzband/django-axes

Issue:
AXES_USE_USER_AGENT not functioning correctly
This might be a duplicate of #677, but the user there did not provide enough detail for me to be sure.

I am using the following versions of Django and Axes:
```
django==3.2.16
django-ipware==4.0.2
django-axes==5.39.0
```

My Axes configuration is as follows:
```python
AXES_FAILURE_LIMIT = 5
AXES_COOLOFF_TIME = dt.timedelta(hours=1)
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
AXES_USERNAME_FORM_FIELD = "email"
AXES_USE_USER_AGENT = True
```

When I reach the 5 failure limit with a given `(ip, user_agent)` pair, I am locked out. If I change to a different user agent, I am still locked out. This is not expected, and the docs do not reflect this behaviour either. If I have `AXES_USE_USER_AGENT = True`, I would expect that using the same IP but a different user agent would reset the count.

It also appears that if, for example, I had 5 attempts with `(ip_1, user_agent_1)`, and then tried to log in with a **different** ip address (let's call it `ip_2`) but the same user agent string, I am *also* blocked out. It appears to lock out via user agent string, even if the IP is different!

Happy to provide more detail if necessary. I can also start digging into the code and/or provide some tests that prove these bugs exist, just haven't had the time yet...


Discussion:
I can confirm that this does not appear to be a problem with `AxesCacheHandler` specifically - I have tested with `AxesDatabaseHandler` as well, and I am seeing the same bug.
This was bug was introduced in commit 128d01158cf5de5208dc7edbd5ecab54e0cd8f7c in the `get_client_parameters` function. The `user_agent` should be being set in the same dict as the `ip_address`, rather than appending a new dict with user agent onto the `filter_query` list
There is not sufficient test coverage around the `AXES_USE_USER_AGENT` feature - this is a fairly major regression!
Thank you for the detailed report @cjtapper. Would you or @PetrDlouhy be interested in issuing a fix for this?
Happy to give feedback / review on any PRs that are opened related to this, but unfortunately I don't have the time to work on the fix myself
I will look into it today or tomorrow.

BTW By coincidence our service was today subject of brute force attack probably by a leaked e-mail - password list. The attacker did probably broke into 3 free (insignificant) accounts.
I realized, that I didn't have the option `AXES_LOCK_OUT_BY_USER_OR_IP` on.
From this experience I would recommend to have it always on.

Resolving patch:
diff --git a/axes/helpers.py b/axes/helpers.py
--- a/axes/helpers.py
+++ b/axes/helpers.py
@@ -205,7 +205,7 @@ def get_client_parameters(username: str, ip_address: str, user_agent: str) -> li
 
         if settings.AXES_USE_USER_AGENT:
             # 4. The HTTP User-Agent can be used to track e.g. one browser
-            filter_query.append({"user_agent": user_agent})
+            filter_query[0]["user_agent"] = user_agent
 
     return filter_query
 


Test patch:
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -329,7 +329,7 @@ def test_get_filter_kwargs_user_or_ip(self):
     def test_get_filter_kwargs_ip_and_agent(self):
         self.assertEqual(
             get_client_parameters(self.username, self.ip_address, self.user_agent),
-            [{"ip_address": self.ip_address}, {"user_agent": self.user_agent}],
+            [{"ip_address": self.ip_address, "user_agent": self.user_agent}],
         )
 
     @override_settings(
@@ -341,8 +341,7 @@ 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},
             ],
         )
 
diff --git a/tests/test_login.py b/tests/test_login.py
--- a/tests/test_login.py
+++ b/tests/test_login.py
@@ -86,7 +86,7 @@ class DatabaseLoginTestCase(AxesTestCase):
     ALLOWED = 302
     BLOCKED = 403
 
-    def _login(self, username, password, ip_addr="127.0.0.1", **kwargs):
+    def _login(self, username, password, ip_addr="127.0.0.1", user_agent="test-browser", **kwargs):
         """
         Login a user and get the response.
 
@@ -101,13 +101,13 @@ def _login(self, username, password, ip_addr="127.0.0.1", **kwargs):
             reverse("admin:login"),
             post_data,
             REMOTE_ADDR=ip_addr,
-            HTTP_USER_AGENT="test-browser",
+            HTTP_USER_AGENT=user_agent,
         )
 
-    def _lockout_user_from_ip(self, username, ip_addr):
+    def _lockout_user_from_ip(self, username, ip_addr, user_agent="test-browser"):
         for _ in range(settings.AXES_FAILURE_LIMIT):
             response = self._login(
-                username=username, password=self.WRONG_PASSWORD, ip_addr=ip_addr
+                username=username, password=self.WRONG_PASSWORD, ip_addr=ip_addr, user_agent=user_agent,
             )
         return response
 
@@ -368,6 +368,19 @@ def test_lockout_by_user_and_ip_with_empty_username_allows_other_users_without_c
         response = self.client.get(reverse("admin:login"), REMOTE_ADDR=self.IP_1)
         self.assertContains(response, self.LOGIN_FORM_KEY, status_code=200, html=True)
 
+    @override_settings(AXES_USE_USER_AGENT=True)
+    def test_lockout_by_user_still_allows_login_with_differnet_user_agent(self):
+        # User with empty username is locked out with "test-browser" user agent.
+        self._lockout_user_from_ip(username="username", ip_addr=self.IP_1, user_agent="test-browser")
+
+        # Test he is locked:
+        response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
+        self.assertEqual(response.status_code, self.BLOCKED)
+
+        # Test with another user agent:
+        response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser-2")
+        self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
+
     # Test for true and false positives when blocking by IP *OR* user (default)
     # With cache enabled. Default criteria.
     def test_lockout_by_ip_blocks_when_same_user_same_ip_using_cache(self):