From 856d74aef35a255032d7960f78564cf7b2e4d899 Mon Sep 17 00:00:00 2001 From: Adeleke Omotayo Date: Tue, 10 Mar 2026 14:22:29 +0000 Subject: [PATCH 1/3] Fix #1159: Fallback to USERNAME_FIELD when AXES_USERNAME_FORM_FIELD not in credentials --- axes/helpers.py | 28 ++++++++++++++++++++-- tests/test_helpers.py | 55 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/axes/helpers.py b/axes/helpers.py index a7ccf60..8c41e68 100644 --- a/axes/helpers.py +++ b/axes/helpers.py @@ -5,6 +5,7 @@ from string import Template from typing import Callable, Optional, Type, Union, List from urllib.parse import urlencode +from django.contrib.auth import get_user_model from django.core.cache import BaseCache, caches from django.http import HttpRequest, HttpResponse, JsonResponse, QueryDict from django.shortcuts import redirect, render @@ -164,14 +165,37 @@ def get_client_username( log.debug( "Using parameter credentials to get username with key settings.AXES_USERNAME_FORM_FIELD" ) - return credentials.get(settings.AXES_USERNAME_FORM_FIELD, None) # type: ignore[return-value] + username = credentials.get(settings.AXES_USERNAME_FORM_FIELD, None) + if username is None: + # Fallback to Django's USERNAME_FIELD for compatibility with + # Django's user_login_failed signal which uses USERNAME_FIELD as the key + # See: https://github.com/jazzband/django-axes/issues/1159 + username_field = get_user_model().USERNAME_FIELD + if username_field != str(settings.AXES_USERNAME_FORM_FIELD): + log.debug( + "Falling back to USERNAME_FIELD '%s' for credentials lookup", + username_field, + ) + username = credentials.get(username_field, None) + return username # type: ignore[return-value] log.debug( "Using parameter request.POST to get username with key settings.AXES_USERNAME_FORM_FIELD" ) request_data = getattr(request, "data", request.POST) - return request_data.get(settings.AXES_USERNAME_FORM_FIELD, None) + username = request_data.get(settings.AXES_USERNAME_FORM_FIELD, None) + if username is None: + # Fallback to Django's USERNAME_FIELD for compatibility + # See: https://github.com/jazzband/django-axes/issues/1159 + username_field = get_user_model().USERNAME_FIELD + if username_field != str(settings.AXES_USERNAME_FORM_FIELD): + log.debug( + "Falling back to USERNAME_FIELD '%s' for request data lookup", + username_field, + ) + username = request_data.get(username_field, None) + return username # type: ignore[return-value] def get_client_ip_address( diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 584cfc4..2e15a3f 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -792,6 +792,61 @@ class UsernameTestCase(AxesTestCase): def test_get_client_username_str(self): self.assertEqual(get_client_username(HttpRequest(), {}), "username") + @override_settings(AXES_USERNAME_FORM_FIELD="auth-username") + def test_get_client_username_fallback_to_username_field_from_credentials(self): + """ + Test that when AXES_USERNAME_FORM_FIELD is set to a custom value that + doesn't exist in credentials, we fallback to Django's USERNAME_FIELD. + + This fixes https://github.com/jazzband/django-axes/issues/1159 + """ + expected = "test-username" + + request = HttpRequest() + # Credentials from Django's user_login_failed signal use USERNAME_FIELD (e.g., "username") + # not the custom AXES_USERNAME_FORM_FIELD value + credentials = {"username": expected} + + actual = get_client_username(request, credentials) + + self.assertEqual(expected, actual) + + @override_settings(AXES_USERNAME_FORM_FIELD="auth-username") + def test_get_client_username_fallback_to_username_field_from_request(self): + """ + Test that when AXES_USERNAME_FORM_FIELD is set to a custom value that + doesn't exist in request.POST, we fallback to Django's USERNAME_FIELD. + + This fixes https://github.com/jazzband/django-axes/issues/1159 + """ + expected = "test-username" + + request = HttpRequest() + # POST data might use Django's USERNAME_FIELD instead of custom form field + request.POST["username"] = expected + + actual = get_client_username(request) + + self.assertEqual(expected, actual) + + @override_settings(AXES_USERNAME_FORM_FIELD="auth-username") + def test_get_client_username_custom_field_takes_priority(self): + """ + Test that AXES_USERNAME_FORM_FIELD takes priority when it exists in credentials. + """ + expected = "custom-field-username" + fallback = "username-field-value" + + request = HttpRequest() + credentials = { + "auth-username": expected, # Custom field - should be used + "username": fallback, # Django's USERNAME_FIELD - should be ignored + } + + actual = get_client_username(request, credentials) + + self.assertEqual(expected, actual) + def get_username(request, credentials: dict) -> str: return "username" From 061f605d596d88ae0d169f0fc92e8f552ff30660 Mon Sep 17 00:00:00 2001 From: Adeleke Omotayo Date: Tue, 10 Mar 2026 14:34:04 +0000 Subject: [PATCH 2/3] docs: Update AXES_USERNAME_FORM_FIELD description with fallback behavior --- docs/4_configuration.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/4_configuration.rst b/docs/4_configuration.rst index 36c4af5..941f496 100644 --- a/docs/4_configuration.rst +++ b/docs/4_configuration.rst @@ -47,7 +47,7 @@ The following ``settings.py`` options are available for customizing Axes behavio +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_VERBOSE | True | If ``True``, you'll see slightly more logging for Axes. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| AXES_USERNAME_FORM_FIELD | 'settings.AUTH_USER_MODEL.USERNAME_FIELD' | The name of the form field that contains your users usernames. | +| AXES_USERNAME_FORM_FIELD | 'settings.AUTH_USER_MODEL.USERNAME_FIELD' | The name of the form field that contains your users usernames. When looking up the username from credentials or request data, Axes will first try this field, then fall back to Django's ``USERNAME_FIELD`` if not found. This ensures compatibility with Django's ``user_login_failed`` signal which uses ``USERNAME_FIELD``. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_USERNAME_CALLABLE | None | A callable or a string path to callable that takes two arguments for user lookups: ``def get_username(request: HttpRequest, credentials: dict) -> str: ...``. This can be any callable such as ``AXES_USERNAME_CALLABLE = lambda request, credentials: 'username'`` or a full Python module path to callable such as ``AXES_USERNAME_CALLABLE = 'example.get_username``. The ``request`` is a HttpRequest like object and the ``credentials`` is a dictionary like object. ``credentials`` are the ones that were passed to Django ``authenticate()`` in the login flow. If no function is supplied, Axes fetches the username from the ``credentials`` or ``request.POST`` dictionaries based on ``AXES_USERNAME_FORM_FIELD``. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ From 694dafc092e390fcb71e5801e5fb87c0a3b54680 Mon Sep 17 00:00:00 2001 From: Adeleke Omotayo Date: Fri, 20 Mar 2026 13:55:39 +0000 Subject: [PATCH 3/3] refactor: Extract get_username_from_data helper to remove duplication --- axes/helpers.py | 53 +++++++++++++++++++++++-------------------- tests/test_helpers.py | 41 +++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 25 deletions(-) diff --git a/axes/helpers.py b/axes/helpers.py index 8c41e68..ed29bdf 100644 --- a/axes/helpers.py +++ b/axes/helpers.py @@ -132,6 +132,32 @@ def get_credentials(username: Optional[str] = None, **kwargs) -> dict: return credentials +def get_username_from_data(data: dict) -> Optional[str]: + """ + Extract username from a dict-like object (credentials or request data). + + Tries AXES_USERNAME_FORM_FIELD first, then falls back to Django's USERNAME_FIELD + for compatibility with Django's user_login_failed signal. + + See: https://github.com/jazzband/django-axes/issues/1159 + + :param data: dict-like object containing username (credentials or request.POST) + :return: username string or None if not found + """ + username = data.get(settings.AXES_USERNAME_FORM_FIELD, None) + if username is None: + # Fallback to Django's USERNAME_FIELD for compatibility with + # Django's user_login_failed signal which uses USERNAME_FIELD as the key + username_field = get_user_model().USERNAME_FIELD + if username_field != str(settings.AXES_USERNAME_FORM_FIELD): + log.debug( + "Falling back to USERNAME_FIELD '%s' for data lookup", + username_field, + ) + username = data.get(username_field, None) + return username + + def get_client_username( request: HttpRequest, credentials: Optional[dict] = None ) -> str: @@ -165,37 +191,14 @@ def get_client_username( log.debug( "Using parameter credentials to get username with key settings.AXES_USERNAME_FORM_FIELD" ) - username = credentials.get(settings.AXES_USERNAME_FORM_FIELD, None) - if username is None: - # Fallback to Django's USERNAME_FIELD for compatibility with - # Django's user_login_failed signal which uses USERNAME_FIELD as the key - # See: https://github.com/jazzband/django-axes/issues/1159 - username_field = get_user_model().USERNAME_FIELD - if username_field != str(settings.AXES_USERNAME_FORM_FIELD): - log.debug( - "Falling back to USERNAME_FIELD '%s' for credentials lookup", - username_field, - ) - username = credentials.get(username_field, None) - return username # type: ignore[return-value] + return get_username_from_data(credentials) # type: ignore[return-value] log.debug( "Using parameter request.POST to get username with key settings.AXES_USERNAME_FORM_FIELD" ) request_data = getattr(request, "data", request.POST) - username = request_data.get(settings.AXES_USERNAME_FORM_FIELD, None) - if username is None: - # Fallback to Django's USERNAME_FIELD for compatibility - # See: https://github.com/jazzband/django-axes/issues/1159 - username_field = get_user_model().USERNAME_FIELD - if username_field != str(settings.AXES_USERNAME_FORM_FIELD): - log.debug( - "Falling back to USERNAME_FIELD '%s' for request data lookup", - username_field, - ) - username = request_data.get(username_field, None) - return username # type: ignore[return-value] + return get_username_from_data(request_data) # type: ignore[return-value] def get_client_ip_address( diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 2e15a3f..09540d4 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -18,6 +18,7 @@ from axes.helpers import ( get_cool_off, get_cool_off_iso8601, get_lockout_response, + get_username_from_data, is_client_ip_address_blacklisted, is_client_ip_address_whitelisted, is_client_method_whitelisted, @@ -848,6 +849,46 @@ class UsernameTestCase(AxesTestCase): self.assertEqual(expected, actual) +class GetUsernameFromDataTestCase(AxesTestCase): + """Unit tests for the get_username_from_data helper function.""" + + @override_settings(AXES_USERNAME_FORM_FIELD="username") + def test_get_username_from_data_with_matching_field(self): + """Test that username is returned when AXES_USERNAME_FORM_FIELD matches.""" + data = {"username": "test-user"} + self.assertEqual(get_username_from_data(data), "test-user") + + @override_settings(AXES_USERNAME_FORM_FIELD="custom-field") + def test_get_username_from_data_fallback_to_username_field(self): + """ + Test that when AXES_USERNAME_FORM_FIELD doesn't exist in data, + we fallback to Django's USERNAME_FIELD. + """ + # Django's default USERNAME_FIELD is "username" + data = {"username": "fallback-user"} + self.assertEqual(get_username_from_data(data), "fallback-user") + + @override_settings(AXES_USERNAME_FORM_FIELD="custom-field") + def test_get_username_from_data_custom_field_takes_priority(self): + """Test that AXES_USERNAME_FORM_FIELD takes priority over USERNAME_FIELD.""" + data = { + "custom-field": "custom-user", + "username": "fallback-user", + } + self.assertEqual(get_username_from_data(data), "custom-user") + + @override_settings(AXES_USERNAME_FORM_FIELD="nonexistent") + def test_get_username_from_data_returns_none_when_not_found(self): + """Test that None is returned when username is not found in any field.""" + data = {"other-field": "some-value"} + self.assertIsNone(get_username_from_data(data)) + + @override_settings(AXES_USERNAME_FORM_FIELD="username") + def test_get_username_from_data_with_empty_dict(self): + """Test that None is returned for empty dict.""" + self.assertIsNone(get_username_from_data({})) + + def get_username(request, credentials: dict) -> str: return "username"