Repository: jazzband/django-axes

Issue:
Potential ValueError for expired AccessAttempt records
In `AxesDatabaseHandler`, `user_login_failed()` can accidentally cause the following `ValueError` if another concurrent process deletes (e.g. `clean_expired_user_attempts`) an existing record retrieved by `AccessAttempt.objects.get_or_create()` before committing `attempt.save()` on the DB.

```python
ValueError: Failed to insert expression "Concat(ConcatPair(Col(axes_accessattempt, axes.AccessAttempt.get_data), Value(
---------
)))" on axes.AccessAttempt.get_data. F() expressions can only be used to update, not to insert.
```

https://github.com/jazzband/django-axes/blob/ac86d4b2139f632cee972e874984982a9628ab27/axes/handlers/database.py#L155


Discussion:
Thanks for reporting @okapies. Do you have a suggestion for fixing this?
One idea is not to run `clean_expired_user_attempts` in the handler. IMHO, we should not remove existing records especially when they have a chance to be updated.
Another option is to develop a new `DatabaseHandler` which stores an `Attempt` record per request. In this case, the handler calculates a failure count by aggregating the saved records, and we can remove the expired ones.
> One idea is not to run `clean_expired_user_attempts` in the handler. IMHO, we should not remove existing records especially when they have a chance to be updated.

How would we distinguish the expired attempts with this approach, and how would expired data be cleaned up?

> Another option is to develop a new `DatabaseHandler` which stores an `Attempt` record per request. In this case, the handler calculates a failure count by aggregating the saved records, and we can remove the expired ones.

This is viable and semantically correct, but what would happen in a case where someone e.g. DDoSes the service? It would be very easy to generate a lot of bloat data into the database in a very short time.
Hmm, or add `select_for_update()` when getting the records?

```python
attempt, created = AccessAttempt.objects.select_for_update().get_or_create(...)
```

`select_for_update` does actually seem like a really good option. I tested it locally, and the queryset API does support using it together with `get_or_create`. Would you like to open a PR for this?

I checked the API reference and most commonly used databases including `mysql` and `postgresql` support the `select_for_update` queryset method at the very least:

https://docs.djangoproject.com/en/3.2/ref/models/querysets/#select-for-update

Resolving patch:
diff --git a/axes/handlers/database.py b/axes/handlers/database.py
--- a/axes/handlers/database.py
+++ b/axes/handlers/database.py
@@ -1,5 +1,6 @@
 from logging import getLogger
 
+from django.db import transaction
 from django.db.models import F, Sum, Value, Q
 from django.db.models.functions import Concat
 from django.utils import timezone
@@ -123,48 +124,54 @@ def user_login_failed(
                 "AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, new record will NOT be created."
             )
         else:
-            attempt, created = AccessAttempt.objects.get_or_create(
-                username=username,
-                ip_address=request.axes_ip_address,
-                user_agent=request.axes_user_agent,
-                defaults={
-                    "get_data": get_data,
-                    "post_data": post_data,
-                    "http_accept": request.axes_http_accept,
-                    "path_info": request.axes_path_info,
-                    "failures_since_start": 1,
-                    "attempt_time": request.axes_attempt_time,
-                },
-            )
-
-            # Record failed attempt with all the relevant information.
-            # Filtering based on username, IP address and user agent handled elsewhere,
-            # and this handler just records the available information for further use.
-            if created:
-                log.warning(
-                    "AXES: New login failure by %s. Created new record in the database.",
-                    client_str,
+            with transaction.atomic():
+                (
+                    attempt,
+                    created,
+                ) = AccessAttempt.objects.select_for_update().get_or_create(
+                    username=username,
+                    ip_address=request.axes_ip_address,
+                    user_agent=request.axes_user_agent,
+                    defaults={
+                        "get_data": get_data,
+                        "post_data": post_data,
+                        "http_accept": request.axes_http_accept,
+                        "path_info": request.axes_path_info,
+                        "failures_since_start": 1,
+                        "attempt_time": request.axes_attempt_time,
+                    },
                 )
 
-            # 3. database query if there were previous attempts in the database
-            # Update failed attempt information but do not touch the username, IP address, or user agent fields,
-            # because attackers can request the site with multiple different configurations
-            # in order to bypass the defense mechanisms that are used by the site.
-            else:
-                separator = "\n---------\n"
-
-                attempt.get_data = Concat("get_data", Value(separator + get_data))
-                attempt.post_data = Concat("post_data", Value(separator + post_data))
-                attempt.http_accept = request.axes_http_accept
-                attempt.path_info = request.axes_path_info
-                attempt.failures_since_start = F("failures_since_start") + 1
-                attempt.attempt_time = request.axes_attempt_time
-                attempt.save()
-
-                log.warning(
-                    "AXES: Repeated login failure by %s. Updated existing record in the database.",
-                    client_str,
-                )
+                # Record failed attempt with all the relevant information.
+                # Filtering based on username, IP address and user agent handled elsewhere,
+                # and this handler just records the available information for further use.
+                if created:
+                    log.warning(
+                        "AXES: New login failure by %s. Created new record in the database.",
+                        client_str,
+                    )
+
+                # 3. database query if there were previous attempts in the database
+                # Update failed attempt information but do not touch the username, IP address, or user agent fields,
+                # because attackers can request the site with multiple different configurations
+                # in order to bypass the defense mechanisms that are used by the site.
+                else:
+                    separator = "\n---------\n"
+
+                    attempt.get_data = Concat("get_data", Value(separator + get_data))
+                    attempt.post_data = Concat(
+                        "post_data", Value(separator + post_data)
+                    )
+                    attempt.http_accept = request.axes_http_accept
+                    attempt.path_info = request.axes_path_info
+                    attempt.failures_since_start = F("failures_since_start") + 1
+                    attempt.attempt_time = request.axes_attempt_time
+                    attempt.save()
+
+                    log.warning(
+                        "AXES: Repeated login failure by %s. Updated existing record in the database.",
+                        client_str,
+                    )
 
         # 3. or 4. database query: Calculate the current maximum failure number from the existing attempts
         failures_since_start = self.get_failures(request, credentials)


Test patch: