Compare commits

..

No commits in common. "master" and "6.5.0" have entirely different histories.

38 changed files with 239 additions and 1009 deletions

View file

@ -14,11 +14,11 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v4 uses: github/codeql-action/init@v3
# Override language selection by uncommenting this and choosing your languages # Override language selection by uncommenting this and choosing your languages
# with: # with:
# languages: go, javascript, csharp, python, cpp, java # languages: go, javascript, csharp, python, cpp, java
@ -26,7 +26,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below). # If this step fails, then you should remove it and run the build manually (see below).
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v4 uses: github/codeql-action/autobuild@v3
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@ -40,4 +40,4 @@ jobs:
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4 uses: github/codeql-action/analyze@v3

View file

@ -14,14 +14,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v5
with: with:
python-version: 3.12 python-version: 3.8
- name: Install dependencies - name: Install dependencies
run: | run: |

View file

@ -11,32 +11,45 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
max-parallel: 5
matrix: matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
django-version: ['4.2', '5.2', '6.0'] django-version: ['3.2', '4.2', '5.0']
include: include:
# Tox configuration for QA environment # Tox configuration for QA environment
- python-version: '3.14' - python-version: '3.12'
django-version: 'qa' django-version: 'qa'
# Django main # Django main
- python-version: '3.14' - python-version: '3.12'
django-version: 'main' django-version: 'main'
experimental: true experimental: true
exclude: # PyPy 3.10
- python-version: '3.13' - python-version: 'pypy-3.10'
django-version: '3.2'
experimental: true
- python-version: 'pypy-3.10'
django-version: '4.2' django-version: '4.2'
- python-version: '3.9' experimental: true
django-version: '5.2' - python-version: 'pypy-3.10'
- python-version: '3.10' django-version: '5.0'
django-version: '6.0' experimental: true
exclude:
- python-version: '3.11' - python-version: '3.11'
django-version: '6.0' django-version: '3.2'
- python-version: '3.12'
django-version: '3.2'
- python-version: '3.8'
django-version: '5.0'
- python-version: '3.9'
django-version: '5.0'
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6 uses: actions/setup-python@v5
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
@ -46,7 +59,7 @@ jobs:
echo "::set-output name=dir::$(pip cache dir)" echo "::set-output name=dir::$(pip cache dir)"
- name: Cache - name: Cache
uses: actions/cache@v5 uses: actions/cache@v4
with: with:
path: ${{ steps.pip-cache.outputs.dir }} path: ${{ steps.pip-cache.outputs.dir }}
key: key:

View file

@ -2,103 +2,6 @@
Changes Changes
======= =======
8.3.1 (2026-02-11)
------------------
- Fix configuration JSON serialization errors for Celery.
[aleksihakli]
8.3.0 (2026-02-09)
------------------
- Remove deprecated pkg_resources in favour of new importlib.
[hugovk]
8.2.0 (2026-02-06)
------------------
- Fix AttributeError when optional settings are undefined.
[rodrigo.nogueira]
- Fix circular import with custom user models.
[rodrigo.nogueira]
- Add unit tests for security check W006.
[shayanTaki]
8.1.0 (2025-12-19)
------------------
- Add Persion (fa) translations for django-axes.
[AmirAli-BahramJerdi]
- Add individual attempt expiry support.
[kuldeepkhatke]
- Add checks for missing ip_address in lockout params.
[shayanTaki]
- Add missing ``settings.AXES_IPWARE_PROXY_ORDER`` documentation.
[ram98kgp]
- Enhance ``get_lockout_response`` to receive original response as parameter.
[mounirmesselmeni]
- Update documentation.
- Add Python 3.14 support.
- Add Django 6.0 support.
- Remove Python 3.9 support (EOL).
- Remove Django 5.1 support (EOL).
8.0.0 (2025-05-10)
------------------
- Move all database related logic to the default ``axes.handlers.database.AxesDatabaseHandler``.
[nefrob]
7.1.0 (2025-04-23)
------------------
- Provide credentials to expired credentials cleanup method.
[parul-aro]
- Update support matrix for Django 5.2.
[mkniewallner]
- Fix documentation.
[chango-goat]
7.0.2 (2025-02-19)
------------------
- Fix documentation.
[Jacobus-afk]
- Default to using ``settings.AUTH_USER_MODEL.USERNAME_FIELD`` for resolving ``settings.AXES_USERNAME_FORM_FIELD`` if otherwise unset (previously "username").
[amneher]
7.0.1 (2024-12-02)
------------------
- Add Python 3.13 support.
[aleksihakli]
- Deprecate Python 3.8 support.
[aleksihakli]
7.0.0 (2024-10-02)
------------------
- Add support for dynamic cooloff time calculation from request. This is a breaking change. Please see `version 7 upgrade notes in the documentation <https://github.com/jazzband/django-axes/blob/4e89d72b92db044ff3f6b23ea2ab2e681211c98e/docs/2_installation.rst#version-7-breaking-changes-and-upgrading-from-django-axes-version-6>`_.
[browniebroke]
6.5.2 (2024-09-21)
------------------
- Add test matrix support for Django 5.1.
- Drop support for EOL Django 3.2.
- Drop support for PyPy 3.10.
6.5.1 (2024-07-01)
------------------
- Make 0007_alter_accessattempt_unique_together.py migration backwards compatible.
[hirotasoshu]
6.5.0 (2024-06-11) 6.5.0 (2024-06-11)
------------------ ------------------

View file

@ -57,8 +57,8 @@ or alternatively use a fast and DDoS resistant cache implementation.
Axes can be configured to monitor login attempts by Axes can be configured to monitor login attempts by
IP address, username, user agent, or their combinations. IP address, username, user agent, or their combinations.
Axes supports cool off periods, IP address allow listing and block listing, Axes supports cool off periods, IP address whitelisting and blacklisting,
user account allow listing, and other features for Django access management. user account whitelisting, and other features for Django access management.
Documentation Documentation

View file

@ -4,59 +4,26 @@ from django.utils.translation import gettext_lazy as _
from axes.conf import settings from axes.conf import settings
from axes.models import AccessAttempt, AccessLog, AccessFailureLog from axes.models import AccessAttempt, AccessLog, AccessFailureLog
from axes.handlers.database import AxesDatabaseHandler
class IsLockedOutFilter(admin.SimpleListFilter):
title = _("Locked Out")
parameter_name = "locked_out"
def lookups(self, request, model_admin):
return (
("yes", _("Yes")),
("no", _("No")),
)
def queryset(self, request, queryset):
if self.value() == "yes":
return queryset.filter(
failures_since_start__gte=settings.AXES_FAILURE_LIMIT
)
if self.value() == "no":
return queryset.filter(failures_since_start__lt=settings.AXES_FAILURE_LIMIT)
return queryset
class AccessAttemptAdmin(admin.ModelAdmin): class AccessAttemptAdmin(admin.ModelAdmin):
list_display = [ list_display = (
"attempt_time", "attempt_time",
"ip_address", "ip_address",
"user_agent", "user_agent",
"username", "username",
"path_info", "path_info",
"failures_since_start", "failures_since_start",
] )
if settings.AXES_USE_ATTEMPT_EXPIRATION:
list_display.append("expiration")
list_filter = ["attempt_time", "path_info"] list_filter = ["attempt_time", "path_info"]
if isinstance(settings.AXES_FAILURE_LIMIT, int) and settings.AXES_FAILURE_LIMIT > 0:
# This will only add the status field if AXES_FAILURE_LIMIT is set to a positive integer
# Because callable failure limit requires scope of request object
list_display.append("status")
list_filter.append(IsLockedOutFilter) # type: ignore[arg-type]
search_fields = ["ip_address", "username", "user_agent", "path_info"] search_fields = ["ip_address", "username", "user_agent", "path_info"]
date_hierarchy = "attempt_time" date_hierarchy = "attempt_time"
fieldsets = ( fieldsets = (
( (None, {"fields": ("username", "path_info", "failures_since_start")}),
None,
{"fields": ("username", "path_info", "failures_since_start", "expiration")},
),
(_("Form Data"), {"fields": ("get_data", "post_data")}), (_("Form Data"), {"fields": ("get_data", "post_data")}),
(_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}), (_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
) )
@ -71,34 +38,11 @@ class AccessAttemptAdmin(admin.ModelAdmin):
"get_data", "get_data",
"post_data", "post_data",
"failures_since_start", "failures_since_start",
"expiration",
] ]
actions = ["cleanup_expired_attempts"]
@admin.action(description=_("Clean up expired attempts"))
def cleanup_expired_attempts(self, request, queryset): # noqa
count = self.handler.clean_expired_user_attempts(request=request)
self.message_user(request, _(f"Cleaned up {count} expired access attempts."))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.handler = AxesDatabaseHandler()
def has_add_permission(self, request: HttpRequest) -> bool: def has_add_permission(self, request: HttpRequest) -> bool:
return False return False
def expiration(self, obj: AccessAttempt):
return obj.expiration.expires_at if hasattr(obj, "expiration") else _("Not set")
def status(self, obj: AccessAttempt):
return (
f"{settings.AXES_FAILURE_LIMIT - obj.failures_since_start} "
+ _("Attempt Remaining")
if obj.failures_since_start < settings.AXES_FAILURE_LIMIT
else _("Locked Out")
)
class AccessLogAdmin(admin.ModelAdmin): class AccessLogAdmin(admin.ModelAdmin):
list_display = ( list_display = (

View file

@ -1,26 +1,106 @@
from logging import getLogger from logging import getLogger
from typing import Optional from typing import List, Optional
from django.db.models import QuerySet
from django.http import HttpRequest from django.http import HttpRequest
from django.utils.timezone import datetime, now from django.utils.timezone import datetime, now
from axes.helpers import get_cool_off from axes.conf import settings
from axes.helpers import get_client_username, get_client_parameters, get_cool_off
from axes.models import AccessAttempt
log = getLogger(__name__) log = getLogger(__name__)
def get_cool_off_threshold(request: Optional[HttpRequest] = None) -> datetime: def get_cool_off_threshold(attempt_time: Optional[datetime] = None) -> datetime:
""" """
Get threshold for fetching access attempts from the database. Get threshold for fetching access attempts from the database.
""" """
cool_off = get_cool_off(request) cool_off = get_cool_off()
if cool_off is None: if cool_off is None:
raise TypeError( raise TypeError(
"Cool off threshold can not be calculated with settings.AXES_COOLOFF_TIME set to None" "Cool off threshold can not be calculated with settings.AXES_COOLOFF_TIME set to None"
) )
attempt_time = request.axes_attempt_time # type: ignore[union-attr]
if attempt_time is None: if attempt_time is None:
return now() - cool_off return now() - cool_off
return attempt_time - cool_off return attempt_time - cool_off
def filter_user_attempts(
request: HttpRequest, credentials: Optional[dict] = None
) -> List[QuerySet]:
"""
Return a list querysets of AccessAttempts that match the given request and credentials.
"""
username = get_client_username(request, credentials)
filter_kwargs_list = get_client_parameters(
username, request.axes_ip_address, request.axes_user_agent, request, credentials
)
attempts_list = [
AccessAttempt.objects.filter(**filter_kwargs)
for filter_kwargs in filter_kwargs_list
]
return attempts_list
def get_user_attempts(
request: HttpRequest, credentials: Optional[dict] = None
) -> List[QuerySet]:
"""
Get list of querysets with valid user attempts that match the given request and credentials.
"""
attempts_list = filter_user_attempts(request, credentials)
if settings.AXES_COOLOFF_TIME is None:
log.debug(
"AXES: Getting all access attempts from database because no AXES_COOLOFF_TIME is configured"
)
return attempts_list
threshold = get_cool_off_threshold(request.axes_attempt_time)
log.debug("AXES: Getting access attempts that are newer than %s", threshold)
return [attempts.filter(attempt_time__gte=threshold) for attempts in attempts_list]
def clean_expired_user_attempts(attempt_time: Optional[datetime] = None) -> int:
"""
Clean expired user attempts from the database.
"""
if settings.AXES_COOLOFF_TIME is None:
log.debug(
"AXES: Skipping clean for expired access attempts because no AXES_COOLOFF_TIME is configured"
)
return 0
threshold = get_cool_off_threshold(attempt_time)
count, _ = AccessAttempt.objects.filter(attempt_time__lt=threshold).delete()
log.info(
"AXES: Cleaned up %s expired access attempts from database that were older than %s",
count,
threshold,
)
return count
def reset_user_attempts(
request: HttpRequest, credentials: Optional[dict] = None
) -> int:
"""
Reset all user attempts that match the given request and credentials.
"""
attempts_list = filter_user_attempts(request, credentials)
count = 0
for attempts in attempts_list:
_count, _ = attempts.delete()
count += _count
log.info("AXES: Reset %s access attempts from database.", count)
return count

View file

@ -22,10 +22,6 @@ class Messages:
BACKEND_INVALID = "You do not have 'axes.backends.AxesStandaloneBackend' or a subclass in your settings.AUTHENTICATION_BACKENDS." BACKEND_INVALID = "You do not have 'axes.backends.AxesStandaloneBackend' or a subclass in your settings.AUTHENTICATION_BACKENDS."
SETTING_DEPRECATED = "You have a deprecated setting {deprecated_setting} configured in your project settings" SETTING_DEPRECATED = "You have a deprecated setting {deprecated_setting} configured in your project settings"
CALLABLE_INVALID = "{callable_setting} is not a valid callable." CALLABLE_INVALID = "{callable_setting} is not a valid callable."
LOCKOUT_PARAMETERS_INVALID = (
"AXES_LOCKOUT_PARAMETERS does not contain 'ip_address'."
" This configuration allows attackers to bypass rate limits by rotating User-Agents or Cookies."
)
class Hints: class Hints:
@ -34,7 +30,6 @@ class Hints:
BACKEND_INVALID = "AxesModelBackend was renamed to AxesStandaloneBackend in django-axes version 5.0." BACKEND_INVALID = "AxesModelBackend was renamed to AxesStandaloneBackend in django-axes version 5.0."
SETTING_DEPRECATED = None SETTING_DEPRECATED = None
CALLABLE_INVALID = None CALLABLE_INVALID = None
LOCKOUT_PARAMETERS_INVALID = "Add 'ip_address' to AXES_LOCKOUT_PARAMETERS."
class Codes: class Codes:
@ -43,7 +38,6 @@ class Codes:
BACKEND_INVALID = "axes.W003" BACKEND_INVALID = "axes.W003"
SETTING_DEPRECATED = "axes.W004" SETTING_DEPRECATED = "axes.W004"
CALLABLE_INVALID = "axes.W005" CALLABLE_INVALID = "axes.W005"
LOCKOUT_PARAMETERS_INVALID = "axes.W006"
@register(Tags.security, Tags.caches, Tags.compatibility) @register(Tags.security, Tags.caches, Tags.compatibility)
@ -164,34 +158,6 @@ def axes_deprecation_check(app_configs, **kwargs): # pylint: disable=unused-arg
return warnings return warnings
@register(Tags.security)
def axes_lockout_params_check(app_configs, **kwargs): # pylint: disable=unused-argument
warnings = []
lockout_params = getattr(settings, "AXES_LOCKOUT_PARAMETERS", None)
if isinstance(lockout_params, (list, tuple)):
has_ip = False
for param in lockout_params:
if param == "ip_address":
has_ip = True
break
if isinstance(param, (list, tuple)) and "ip_address" in param:
has_ip = True
break
if not has_ip:
warnings.append(
Warning(
msg=Messages.LOCKOUT_PARAMETERS_INVALID,
hint=Hints.LOCKOUT_PARAMETERS_INVALID,
id=Codes.LOCKOUT_PARAMETERS_INVALID,
)
)
return warnings
@register @register
def axes_conf_check(app_configs, **kwargs): # pylint: disable=unused-argument def axes_conf_check(app_configs, **kwargs): # pylint: disable=unused-argument
warnings = [] warnings = []
@ -207,7 +173,7 @@ def axes_conf_check(app_configs, **kwargs): # pylint: disable=unused-argument
] ]
for callable_setting in callable_settings: for callable_setting in callable_settings:
value = getattr(settings, callable_setting, None) value = getattr(settings, callable_setting)
if not is_valid_callable(value): if not is_valid_callable(value):
warnings.append( warnings.append(
Warning( Warning(

View file

@ -1,21 +1,6 @@
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.utils.functional import SimpleLazyObject
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
class JSONSerializableLazyObject(SimpleLazyObject):
"""
Celery/Kombu config inspection may JSON-encode Django settings.
Provide a JSON-friendly representation for lazy values.
Fixes jazzband/django-axes#1391
"""
def __json__(self):
return str(self)
# disable plugin when set to False # disable plugin when set to False
settings.AXES_ENABLED = getattr(settings, "AXES_ENABLED", True) settings.AXES_ENABLED = getattr(settings, "AXES_ENABLED", True)
@ -56,16 +41,9 @@ settings.AXES_ONLY_ADMIN_SITE = getattr(settings, "AXES_ONLY_ADMIN_SITE", False)
# show Axes logs in admin # show Axes logs in admin
settings.AXES_ENABLE_ADMIN = getattr(settings, "AXES_ENABLE_ADMIN", True) settings.AXES_ENABLE_ADMIN = getattr(settings, "AXES_ENABLE_ADMIN", True)
# use a specific username field to retrieve from login POST data # use a specific username field to retrieve from login POST data
def _get_username_field_default():
return get_user_model().USERNAME_FIELD
settings.AXES_USERNAME_FORM_FIELD = getattr( settings.AXES_USERNAME_FORM_FIELD = getattr(
settings, settings, "AXES_USERNAME_FORM_FIELD", "username"
"AXES_USERNAME_FORM_FIELD",
JSONSerializableLazyObject(_get_username_field_default),
) )
# use a specific password field to retrieve from login POST data # use a specific password field to retrieve from login POST data
@ -108,10 +86,6 @@ settings.AXES_LOCKOUT_URL = getattr(settings, "AXES_LOCKOUT_URL", None)
settings.AXES_COOLOFF_TIME = getattr(settings, "AXES_COOLOFF_TIME", None) settings.AXES_COOLOFF_TIME = getattr(settings, "AXES_COOLOFF_TIME", None)
settings.AXES_USE_ATTEMPT_EXPIRATION = getattr(
settings, "AXES_USE_ATTEMPT_EXPIRATION", False
)
settings.AXES_VERBOSE = getattr(settings, "AXES_VERBOSE", settings.AXES_ENABLED) settings.AXES_VERBOSE = getattr(settings, "AXES_VERBOSE", settings.AXES_ENABLED)
# whitelist and blacklist # whitelist and blacklist

View file

@ -113,7 +113,7 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
return return
cache_keys = get_client_cache_keys(request, credentials) cache_keys = get_client_cache_keys(request, credentials)
cache_timeout = get_cache_timeout(request) cache_timeout = get_cache_timeout()
failures = [] failures = []
for cache_key in cache_keys: for cache_key in cache_keys:
added = self.cache.add(key=cache_key, value=1, timeout=cache_timeout) added = self.cache.add(key=cache_key, value=1, timeout=cache_timeout)

View file

@ -1,17 +1,19 @@
from logging import getLogger from logging import getLogger
from typing import List, Optional from typing import Optional
from django.db import router, transaction from django.db import router, transaction
from django.db.models import F, Q, QuerySet, Sum, Value from django.db.models import F, Q, Sum, Value
from django.db.models.functions import Concat from django.db.models.functions import Concat
from django.http import HttpRequest
from django.utils import timezone from django.utils import timezone
from axes.attempts import get_cool_off_threshold from axes.attempts import (
clean_expired_user_attempts,
get_user_attempts,
reset_user_attempts,
)
from axes.conf import settings from axes.conf import settings
from axes.handlers.base import AbstractAxesHandler, AxesBaseHandler from axes.handlers.base import AxesBaseHandler, AbstractAxesHandler
from axes.helpers import ( from axes.helpers import (
get_client_parameters,
get_client_session_hash, get_client_session_hash,
get_client_str, get_client_str,
get_client_username, get_client_username,
@ -19,14 +21,8 @@ from axes.helpers import (
get_failure_limit, get_failure_limit,
get_lockout_parameters, get_lockout_parameters,
get_query_str, get_query_str,
get_attempt_expiration,
)
from axes.models import (
AccessAttempt,
AccessAttemptExpiration,
AccessFailureLog,
AccessLog,
) )
from axes.models import AccessLog, AccessAttempt, AccessFailureLog
from axes.signals import user_locked_out from axes.signals import user_locked_out
log = getLogger(__name__) log = getLogger(__name__)
@ -108,7 +104,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
return count return count
def get_failures(self, request, credentials: Optional[dict] = None) -> int: def get_failures(self, request, credentials: Optional[dict] = None) -> int:
attempts_list = self.get_user_attempts(request, credentials) attempts_list = get_user_attempts(request, credentials)
attempt_count = max( attempt_count = max(
( (
attempts.aggregate(Sum("failures_since_start"))[ attempts.aggregate(Sum("failures_since_start"))[
@ -121,10 +117,10 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
return attempt_count return attempt_count
def user_login_failed(self, sender, credentials: dict, request=None, **kwargs): def user_login_failed(self, sender, credentials: dict, request=None, **kwargs):
""" """When user login fails, save AccessFailureLog record in database,
When user login fails, save AccessFailureLog record in database,
save AccessAttempt record in database, mark request with save AccessAttempt record in database, mark request with
lockout attribute and emit lockout signal. lockout attribute and emit lockout signal.
""" """
log.info("AXES: User login failed, running database handler for failure.") log.info("AXES: User login failed, running database handler for failure.")
@ -136,7 +132,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
return return
# 1. database query: Clean up expired user attempts from the database before logging new attempts # 1. database query: Clean up expired user attempts from the database before logging new attempts
self.clean_expired_user_attempts(request, credentials) clean_expired_user_attempts(request.axes_attempt_time)
username = get_client_username(request, credentials) username = get_client_username(request, credentials)
client_str = get_client_str( client_str = get_client_str(
@ -225,23 +221,6 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
client_str, client_str,
) )
if settings.AXES_USE_ATTEMPT_EXPIRATION:
if not hasattr(attempt, "expiration") or attempt.expiration is None:
log.debug(
"AXES: Creating new AccessAttemptExpiration for %s",
client_str,
)
attempt.expiration = AccessAttemptExpiration.objects.create(
access_attempt=attempt,
expires_at=get_attempt_expiration(request),
)
else:
attempt.expiration.expires_at = max(
get_attempt_expiration(request),
attempt.expiration.expires_at,
)
attempt.expiration.save()
# 3. or 4. database query: Calculate the current maximum failure number from the existing attempts # 3. or 4. database query: Calculate the current maximum failure number from the existing attempts
failures_since_start = self.get_failures(request, credentials) failures_since_start = self.get_failures(request, credentials)
request.axes_failures_since_start = failures_since_start request.axes_failures_since_start = failures_since_start
@ -282,6 +261,9 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
When user logs in, update the AccessLog related to the user. When user logs in, update the AccessLog related to the user.
""" """
# 1. database query: Clean up expired user attempts from the database
clean_expired_user_attempts(request.axes_attempt_time)
username = user.get_username() username = user.get_username()
credentials = get_credentials(username) credentials = get_credentials(username)
client_str = get_client_str( client_str = get_client_str(
@ -294,9 +276,6 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
log.info("AXES: Successful login by %s.", client_str) log.info("AXES: Successful login by %s.", client_str)
# 1. database query: Clean up expired user attempts from the database
self.clean_expired_user_attempts(request, credentials)
if not settings.AXES_DISABLE_ACCESS_LOG: if not settings.AXES_DISABLE_ACCESS_LOG:
# 2. database query: Insert new access logs with login time # 2. database query: Insert new access logs with login time
AccessLog.objects.create( AccessLog.objects.create(
@ -313,7 +292,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
if settings.AXES_RESET_ON_SUCCESS: if settings.AXES_RESET_ON_SUCCESS:
# 3. database query: Reset failed attempts for the logging in user # 3. database query: Reset failed attempts for the logging in user
count = self.reset_user_attempts(request, credentials) count = reset_user_attempts(request, credentials)
log.info( log.info(
"AXES: Deleted %d failed login attempts by %s from database.", "AXES: Deleted %d failed login attempts by %s from database.",
count, count,
@ -325,8 +304,10 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
When user logs out, update the AccessLog related to the user. When user logs out, update the AccessLog related to the user.
""" """
# 1. database query: Clean up expired user attempts from the database
clean_expired_user_attempts(request.axes_attempt_time)
username = user.get_username() if user else None username = user.get_username() if user else None
credentials = get_credentials(username) if username else None
client_str = get_client_str( client_str = get_client_str(
username, username,
request.axes_ip_address, request.axes_ip_address,
@ -335,9 +316,6 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
request, request,
) )
# 1. database query: Clean up expired user attempts from the database
self.clean_expired_user_attempts(request, credentials)
log.info("AXES: Successful logout by %s.", client_str) log.info("AXES: Successful logout by %s.", client_str)
if username and not settings.AXES_DISABLE_ACCESS_LOG: if username and not settings.AXES_DISABLE_ACCESS_LOG:
@ -349,103 +327,6 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
session_hash=get_client_session_hash(request), session_hash=get_client_session_hash(request),
).update(logout_time=request.axes_attempt_time) ).update(logout_time=request.axes_attempt_time)
def filter_user_attempts(
self, request: HttpRequest, credentials: Optional[dict] = None
) -> List[QuerySet]:
"""
Return a list querysets of AccessAttempts that match the given request and credentials.
"""
username = get_client_username(request, credentials)
filter_kwargs_list = get_client_parameters(
username,
request.axes_ip_address,
request.axes_user_agent,
request,
credentials,
)
attempts_list = [
AccessAttempt.objects.filter(**filter_kwargs)
for filter_kwargs in filter_kwargs_list
]
return attempts_list
def get_user_attempts(
self, request: HttpRequest, credentials: Optional[dict] = None # noqa
) -> List[QuerySet]:
"""
Get list of querysets with valid user attempts that match the given request and credentials.
"""
attempts_list = self.filter_user_attempts(request, credentials)
if settings.AXES_COOLOFF_TIME is None:
log.debug(
"AXES: Getting all access attempts from database because no AXES_COOLOFF_TIME is configured"
)
return attempts_list
threshold = get_cool_off_threshold(request)
log.debug("AXES: Getting access attempts that are newer than %s", threshold)
return [
attempts.filter(attempt_time__gte=threshold) for attempts in attempts_list
]
def clean_expired_user_attempts(
self,
request: Optional[HttpRequest] = None,
credentials: Optional[dict] = None, # noqa
) -> int:
"""
Clean expired user attempts from the database.
"""
if settings.AXES_COOLOFF_TIME is None:
log.debug(
"AXES: Skipping clean for expired access attempts because no AXES_COOLOFF_TIME is configured"
)
return 0
if settings.AXES_USE_ATTEMPT_EXPIRATION:
threshold = timezone.now()
count, _ = AccessAttempt.objects.filter(
expiration__expires_at__lte=threshold
).delete()
log.info(
"AXES: Cleaned up %s expired access attempts from database that expiry were older than %s",
count,
threshold,
)
else:
threshold = get_cool_off_threshold(request)
count, _ = AccessAttempt.objects.filter(
attempt_time__lte=threshold
).delete()
log.info(
"AXES: Cleaned up %s expired access attempts from database that were older than %s",
count,
threshold,
)
return count
def reset_user_attempts(
self, request: HttpRequest, credentials: Optional[dict] = None
) -> int:
"""
Reset all user attempts that match the given request and credentials.
"""
attempts_list = self.filter_user_attempts(request, credentials)
count = 0
for attempts in attempts_list:
_count, _ = attempts.delete()
count += _count
log.info("AXES: Reset %s access attempts from database.", count)
return count
def post_save_access_attempt(self, instance, **kwargs): def post_save_access_attempt(self, instance, **kwargs):
""" """
Handles the ``axes.models.AccessAttempt`` object post save signal. Handles the ``axes.models.AccessAttempt`` object post save signal.

View file

@ -1,4 +1,4 @@
from datetime import timedelta, datetime from datetime import timedelta
from hashlib import sha256 from hashlib import sha256
from logging import getLogger from logging import getLogger
from string import Template from string import Template
@ -32,7 +32,7 @@ def get_cache() -> BaseCache:
return caches[getattr(settings, "AXES_CACHE", "default")] return caches[getattr(settings, "AXES_CACHE", "default")]
def get_cache_timeout(request: Optional[HttpRequest] = None) -> Optional[int]: def get_cache_timeout() -> Optional[int]:
""" """
Return the cache timeout interpreted from settings.AXES_COOLOFF_TIME. Return the cache timeout interpreted from settings.AXES_COOLOFF_TIME.
@ -43,22 +43,21 @@ def get_cache_timeout(request: Optional[HttpRequest] = None) -> Optional[int]:
for use with the Django cache backends. for use with the Django cache backends.
""" """
cool_off = get_cool_off(request) cool_off = get_cool_off()
if cool_off is None: if cool_off is None:
return None return None
return int(cool_off.total_seconds()) return int(cool_off.total_seconds())
def get_cool_off(request: Optional[HttpRequest] = None) -> Optional[timedelta]: def get_cool_off() -> Optional[timedelta]:
""" """
Return the login cool off time interpreted from settings.AXES_COOLOFF_TIME. Return the login cool off time interpreted from settings.AXES_COOLOFF_TIME.
The return value is either None or timedelta. The return value is either None or timedelta.
Notice that the settings.AXES_COOLOFF_TIME is either None, timedelta, integer/float of hours, Notice that the settings.AXES_COOLOFF_TIME is either None, timedelta, or integer/float of hours,
a path to a callable or a callable taking 1 argument (the request). This function and this function offers a unified _timedelta or None_ representation of that configuration
offers a unified _timedelta or None_ representation of that configuration for use with the for use with the Axes internal implementations.
Axes internal implementations.
:exception TypeError: if settings.AXES_COOLOFF_TIME is of wrong type. :exception TypeError: if settings.AXES_COOLOFF_TIME is of wrong type.
""" """
@ -70,10 +69,9 @@ def get_cool_off(request: Optional[HttpRequest] = None) -> Optional[timedelta]:
if isinstance(cool_off, float): if isinstance(cool_off, float):
return timedelta(minutes=cool_off * 60) return timedelta(minutes=cool_off * 60)
if isinstance(cool_off, str): if isinstance(cool_off, str):
cool_off_func = import_string(cool_off) return import_string(cool_off)()
return cool_off_func(request)
if callable(cool_off): if callable(cool_off):
return cool_off(request) # pylint: disable=not-callable return cool_off() # pylint: disable=not-callable
return cool_off return cool_off
@ -101,23 +99,6 @@ def get_cool_off_iso8601(delta: timedelta) -> str:
return f"P{days_str}" return f"P{days_str}"
def get_attempt_expiration(request: Optional[HttpRequest] = None) -> datetime:
"""
Get threshold for fetching access attempts from the database.
"""
cool_off = get_cool_off(request)
if cool_off is None:
raise TypeError(
"Cool off threshold can not be calculated with settings.AXES_COOLOFF_TIME set to None"
)
attempt_time = request.axes_attempt_time # type: ignore[union-attr]
if attempt_time is None:
return datetime.now() + cool_off
return attempt_time + cool_off
def get_credentials(username: Optional[str] = None, **kwargs) -> dict: def get_credentials(username: Optional[str] = None, **kwargs) -> dict:
""" """
Calculate credentials for Axes to use internally from given username and kwargs. Calculate credentials for Axes to use internally from given username and kwargs.
@ -164,7 +145,7 @@ def get_client_username(
log.debug( log.debug(
"Using parameter credentials to get username with key settings.AXES_USERNAME_FORM_FIELD" "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] return credentials.get(settings.AXES_USERNAME_FORM_FIELD, None)
log.debug( log.debug(
"Using parameter request.POST to get username with key settings.AXES_USERNAME_FORM_FIELD" "Using parameter request.POST to get username with key settings.AXES_USERNAME_FORM_FIELD"
@ -462,27 +443,15 @@ def get_lockout_message() -> str:
def get_lockout_response( def get_lockout_response(
request: HttpRequest, request: HttpRequest, credentials: Optional[dict] = None
original_response: Optional[HttpResponse] = None,
credentials: Optional[dict] = None,
) -> HttpResponse: ) -> HttpResponse:
if settings.AXES_LOCKOUT_CALLABLE: if settings.AXES_LOCKOUT_CALLABLE:
if callable(settings.AXES_LOCKOUT_CALLABLE): if callable(settings.AXES_LOCKOUT_CALLABLE):
# Try calling with 3 args, fallback to 2 for backward compatibility return settings.AXES_LOCKOUT_CALLABLE( # pylint: disable=not-callable
try: request, credentials
return settings.AXES_LOCKOUT_CALLABLE( )
request, original_response, credentials
)
except TypeError:
# Fallback: old signature without original_response
return settings.AXES_LOCKOUT_CALLABLE(request, credentials)
if isinstance(settings.AXES_LOCKOUT_CALLABLE, str): if isinstance(settings.AXES_LOCKOUT_CALLABLE, str):
callable_obj = import_string(settings.AXES_LOCKOUT_CALLABLE) return import_string(settings.AXES_LOCKOUT_CALLABLE)(request, credentials)
# Try calling with 3 args, fallback to 2 for backward compatibility
try:
return callable_obj(request, original_response, credentials)
except TypeError:
return callable_obj(request, credentials)
raise TypeError( raise TypeError(
"settings.AXES_LOCKOUT_CALLABLE needs to be a string, callable, or None." "settings.AXES_LOCKOUT_CALLABLE needs to be a string, callable, or None."
) )
@ -493,7 +462,7 @@ def get_lockout_response(
"username": get_client_username(request, credentials) or "", "username": get_client_username(request, credentials) or "",
} }
cool_off = get_cool_off(request) cool_off = get_cool_off()
if cool_off: if cool_off:
context.update( context.update(
{ {

Binary file not shown.

View file

@ -1,109 +0,0 @@
# ترجمه فارسی برای django-axes
# Copyright (C) 2025 jazzband
# This file is distributed under the same license as the django-axes package.
# AmirAli Bahramjerdi <amiralibahramjerdi@gmail.com>, 2025.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: django-axes\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-16 23:28+0330\n"
"PO-Revision-Date: 2025-05-16 23:30+0330\n"
"Last-Translator: AmirAli Bahramjerdi <amiralibahramjerdi@gmail.com>"
"Language-Team: فارسی <fa@li.org>\n"
"Language: fa\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: admin.py:27
msgid "Form Data"
msgstr "داده‌های فرم"
#: admin.py:28 admin.py:65 admin.py:100
msgid "Meta Data"
msgstr "فراداده"
#: conf.py:109
msgid "Account locked: too many login attempts. Please try again later."
msgstr "حساب کاربری قفل شد: تلاش‌های زیادی برای ورود انجام شده است. لطفاً بعداً دوباره امتحان کنید."
#: conf.py:117
msgid ""
"Account locked: too many login attempts. Contact an admin to unlock your "
"account."
msgstr "حساب کاربری قفل شد: تلاش‌های زیادی برای ورود انجام شده است. برای باز کردن حساب با مدیر تماس بگیرید."
#: models.py:6
msgid "User Agent"
msgstr "عامل کاربر (User Agent)"
#: models.py:8
msgid "IP Address"
msgstr "آدرس IP"
#: models.py:10
msgid "Username"
msgstr "نام کاربری"
#: models.py:12
msgid "HTTP Accept"
msgstr "پذیرش HTTP"
#: models.py:14
msgid "Path"
msgstr "مسیر"
#: models.py:16
msgid "Attempt Time"
msgstr "زمان تلاش"
#: models.py:26
msgid "Access lock out"
msgstr "قفل دسترسی"
#: models.py:34
msgid "access failure"
msgstr "شکست در دسترسی"
#: models.py:35
msgid "access failures"
msgstr "شکست‌های دسترسی"
#: models.py:39
msgid "GET Data"
msgstr "داده‌های GET"
#: models.py:41
msgid "POST Data"
msgstr "داده‌های POST"
#: models.py:43
msgid "Failed Logins"
msgstr "ورودهای ناموفق"
#: models.py:49
msgid "access attempt"
msgstr "تلاش برای دسترسی"
#: models.py:50
msgid "access attempts"
msgstr "تلاش‌های دسترسی"
#: models.py:55
msgid "Logout Time"
msgstr "زمان خروج"
#: models.py:56
msgid "Session key hash (sha256)"
msgstr "هش کلید نشست (sha256)"
#: models.py:62
msgid "access log"
msgstr "گزارش دسترسی"
#: models.py:63
msgid "access logs"
msgstr "گزارش‌های دسترسی"

Binary file not shown.

View file

@ -48,7 +48,7 @@ class AxesMiddleware:
if settings.AXES_ENABLED: if settings.AXES_ENABLED:
if getattr(request, "axes_locked_out", None): if getattr(request, "axes_locked_out", None):
credentials = getattr(request, "axes_credentials", None) credentials = getattr(request, "axes_credentials", None)
response = get_lockout_response(request, response, credentials) # type: ignore response = get_lockout_response(request, credentials) # type: ignore
return response return response

View file

@ -35,9 +35,7 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.RunPython( migrations.RunPython(deduplicate_attempts),
deduplicate_attempts, reverse_code=migrations.RunPython.noop
),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name="accessattempt", name="accessattempt",
unique_together={("username", "ip_address", "user_agent")}, unique_together={("username", "ip_address", "user_agent")},

View file

@ -1,41 +0,0 @@
# Generated by Django 5.2.1 on 2025-06-10 20:21
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("axes", "0009_add_session_hash"),
]
operations = [
migrations.CreateModel(
name="AccessAttemptExpiration",
fields=[
(
"access_attempt",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
primary_key=True,
related_name="expiration",
serialize=False,
to="axes.accessattempt",
verbose_name="Access Attempt",
),
),
(
"expires_at",
models.DateTimeField(
help_text="The time when access attempt expires and is no longer valid.",
verbose_name="Expires At",
),
),
],
options={
"verbose_name": "access attempt expiration",
"verbose_name_plural": "access attempt expirations",
},
),
]

View file

@ -51,29 +51,9 @@ class AccessAttempt(AccessBase):
unique_together = [["username", "ip_address", "user_agent"]] unique_together = [["username", "ip_address", "user_agent"]]
class AccessAttemptExpiration(models.Model):
access_attempt = models.OneToOneField(
AccessAttempt,
primary_key=True,
on_delete=models.CASCADE,
related_name="expiration",
verbose_name=_("Access Attempt"),
)
expires_at = models.DateTimeField(
_("Expires At"),
help_text=_("The time when access attempt expires and is no longer valid."),
)
class Meta:
verbose_name = _("access attempt expiration")
verbose_name_plural = _("access attempt expirations")
class AccessLog(AccessBase): class AccessLog(AccessBase):
logout_time = models.DateTimeField(_("Logout Time"), null=True, blank=True) logout_time = models.DateTimeField(_("Logout Time"), null=True, blank=True)
session_hash = models.CharField( session_hash = models.CharField(_("Session key hash (sha256)"), default="", blank=True, max_length=64)
_("Session key hash (sha256)"), default="", blank=True, max_length=64
)
def __str__(self): def __str__(self):
return f"Access Log for {self.username} @ {self.attempt_time}" return f"Access Log for {self.username} @ {self.attempt_time}"

View file

@ -3,7 +3,7 @@
Requirements Requirements
============ ============
Axes requires a supported Django version and runs on Python versions 3.9 and above. Axes requires a supported Django version and runs on Python versions 3.8 and above.
Refer to the project source code repository in Refer to the project source code repository in
`GitHub <https://github.com/jazzband/django-axes/>`_ and see the `GitHub <https://github.com/jazzband/django-axes/>`_ and see the

View file

@ -56,9 +56,6 @@ if you have any custom logic to override Django's standard permissions checks.
# on failed user authentication attempts from login views. # on failed user authentication attempts from login views.
# If you do not want Axes to override the authentication response # If you do not want Axes to override the authentication response
# you can skip installing the middleware and use your own views. # you can skip installing the middleware and use your own views.
# AxesMiddleware runs during the reponse phase. It does not conflict
# with middleware that runs in the request phase like
# django.middleware.cache.FetchFromCacheMiddleware.
'axes.middleware.AxesMiddleware', 'axes.middleware.AxesMiddleware',
] ]
@ -78,20 +75,6 @@ Many people have different configurations for their development and production e
and running the application with misconfigured settings can prevent security features from working. and running the application with misconfigured settings can prevent security features from working.
Version 8 breaking changes and upgrading from django-axes version 7
-------------------------------------------------------------------
Some database related utility functions have moved from ``axes.helpers`` to ``axes.handlers.database`` module and under the ``axes.handlers.database.AxesDatabaseHandler`` class.
Version 7 breaking changes and upgrading from django-axes version 6
-------------------------------------------------------------------
If you use ``settings.AXES_COOLOFF_TIME`` for configuring a callable that returns the cooloff time, it needs to accept at minimum a ``request`` argument of type ``HttpRequest`` from version 7 onwards. Example: ``AXES_COOLOFF_TIME = lambda request: timedelta(hours=2)`` (new call signature) instead of ``AXES_COOLOFF_TIME = lambda: timedelta(hours=2)`` (old cal signature).
Please see configuration documentation and `jazzband/django-axes#1222 <https://github.com/jazzband/django-axes/pull/1222>`_ for reference.
Version 6 breaking changes and upgrading from django-axes version 5 Version 6 breaking changes and upgrading from django-axes version 5
------------------------------------------------------------------- -------------------------------------------------------------------

View file

@ -19,13 +19,11 @@ The following ``settings.py`` options are available for customizing Axes behavio
+======================================================+==============================================+===========================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================+ +======================================================+==============================================+===========================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================+
| AXES_ENABLED | True | Enable or disable Axes plugin functionality, for example in test runner setup | | AXES_ENABLED | True | Enable or disable Axes plugin functionality, for example in test runner setup |
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| AXES_FAILURE_LIMIT | 3 | The integer number of login attempts allowed before the request is considered locked. This can also be a callable or a dotted path to callable that returns an integer and all of the following are valid: ``AXES_FAILURE_LIMIT = 42``, ``AXES_FAILURE_LIMIT = lambda *args: 42``, and ``AXES_FAILURE_LIMIT = 'project.app.get_login_failure_limit'``. | | AXES_FAILURE_LIMIT | 3 | The integer number of login attempts allowed before a record is created for the failed logins. This can also be a callable or a dotted path to callable that returns an integer and all of the following are valid: ``AXES_FAILURE_LIMIT = 42``, ``AXES_FAILURE_LIMIT = lambda *args: 42``, and ``AXES_FAILURE_LIMIT = 'project.app.get_login_failure_limit'``. |
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| AXES_LOCK_OUT_AT_FAILURE | True | After the number of allowed login attempts are exceeded, should we lock out this IP (and optional user agent)? | | AXES_LOCK_OUT_AT_FAILURE | True | After the number of allowed login attempts are exceeded, should we lock out this IP (and optional user agent)? |
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| AXES_COOLOFF_TIME | None | If set, defines the cool-off period after which old failed login attempts are cleared. If ``None``, lockout is permanent until attempts are manually reset. Can be set to a Python timedelta object, an integer, a float, a callable, or a string path to a callable that takes the request as argument. If an integer or float, this is interpreted as hours (``1`` is 1 hour, ``0.5`` is 30 minutes, ``1.7`` is 6120 seconds). ``timedelta`` is recommended for clarity. See also ``AXES_USE_ATTEMPT_EXPIRATION`` for rolling-window behavior. | | AXES_COOLOFF_TIME | None | If set, defines a period of inactivity after which old failed login attempts will be cleared. Can be set to a Python timedelta object, an integer, a float, a callable, or a string path to a callable which takes no arguments. If an integer or float, will be interpreted as a number of hours: ``AXES_COOLOFF_TIME = 2`` 2 hours, ``AXES_COOLOFF_TIME = 2.0`` 2 hours, 120 minutes, ``AXES_COOLOFF_TIME = 1.7`` 1.7 hours, 102 minutes, 6120 seconds |
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| AXES_USE_ATTEMPT_EXPIRATION | False | If ``True``, changes ``AXES_COOLOFF_TIME`` to a rolling window where each failed attempt expires individually after the cool-off time. This enables policies like "3 failed login attempts per 15 minutes". If ``False``, ``AXES_COOLOFF_TIME`` acts as an inactivity period where attempts are cleared only after no new failures occur within the cool-off limit. |
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| AXES_ONLY_ADMIN_SITE | False | If ``True``, lock is only enabled for admin site. Admin site is determined by checking request path against the path of ``"admin:index"`` view. If admin urls are not registered in current urlconf, all requests will not be locked. | | AXES_ONLY_ADMIN_SITE | False | If ``True``, lock is only enabled for admin site. Admin site is determined by checking request path against the path of ``"admin:index"`` view. If admin urls are not registered in current urlconf, all requests will not be locked. |
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
@ -49,15 +47,15 @@ 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_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 | 'username' | The name of the form field that contains your users usernames. |
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| 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``. | | 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``. |
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| AXES_WHITELIST_CALLABLE | None | A callable or a string path to callable that takes two arguments for whitelisting determination and returns True, if user should be whitelisted: ``def is_whitelisted(request: HttpRequest, credentials: dict) -> bool: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. | | AXES_WHITELIST_CALLABLE | None | A callable or a string path to callable that takes two arguments for whitelisting determination and returns True, if user should be whitelisted: ``def is_whitelisted(request: HttpRequest, credentials: dict) -> bool: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. |
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| AXES_LOCKOUT_CALLABLE | None | A callable or a string path to callable that takes three arguments returns a response. For example: ``def generate_lockout_response(request: HttpRequest, original_response: HttpResponse, credentials: dict) -> HttpResponse: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. If not callable is defined, then the default implementation in ``axes.helpers.get_lockout_response`` is used for determining the correct lockout response that is sent to the requesting client. | | AXES_LOCKOUT_CALLABLE | None | A callable or a string path to callable that takes two arguments returns a response. For example: ``def generate_lockout_response(request: HttpRequest, credentials: dict) -> HttpResponse: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. If not callable is defined, then the default implementation in ``axes.helpers.get_lockout_response`` is used for determining the correct lockout response that is sent to the requesting client. |
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| AXES_CLIENT_IP_CALLABLE | None | A callable or a string path to callable that takes HttpRequest as an argument and returns the resolved IP as a response. For example: ``def get_ip(request: HttpRequest) -> str: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. If not callable is defined, then the default implementation in ``axes.helpers.get_client_ip_address`` is used. | | AXES_CLIENT_IP_CALLABLE | None | A callable or a string path to callable that takes two arguments returns a response. For example: ``def get_ip(request: HttpRequest) -> str: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. If not callable is defined, then the default implementation in ``axes.helpers.get_client_ip_address`` is used. |
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| AXES_PASSWORD_FORM_FIELD | 'password' | The name of the form or credentials field that contains your users password. | | AXES_PASSWORD_FORM_FIELD | 'password' | The name of the form or credentials field that contains your users password. |
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
@ -83,28 +81,11 @@ The following ``settings.py`` options are available for customizing Axes behavio
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| AXES_HTTP_RESPONSE_CODE | 429 | Sets the http response code returned when ``AXES_FAILURE_LIMIT`` is reached. For example: ``AXES_HTTP_RESPONSE_CODE = 403`` | | AXES_HTTP_RESPONSE_CODE | 429 | Sets the http response code returned when ``AXES_FAILURE_LIMIT`` is reached. For example: ``AXES_HTTP_RESPONSE_CODE = 403`` |
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT | True | If ``True``, any failed login attempt during lockout resets the cool-off timer to ``now() + AXES_COOLOFF_TIME``. Repeated failed attempts keep extending the lockout period. | | AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT | True | If ``True``, a failed login attempt during lockout will reset the cool off period. |
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| AXES_LOCKOUT_PARAMETERS | ["ip_address"] | A list of parameters that Axes uses to lock out users. It can also be callable, which takes an http request or AccesAttempt object and credentials and returns a list of parameters. Each parameter can be a string (a single parameter) or a list of strings (a combined parameter). For example, if you configure ``AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]``, axes will block clients by ip and/or username and user agent combination. See :ref:`customizing-lockout-parameters` for more details. | | AXES_LOCKOUT_PARAMETERS | ["ip_address"] | A list of parameters that Axes uses to lock out users. It can also be callable, which takes an http request or AccesAttempt object and credentials and returns a list of parameters. Each parameter can be a string (a single parameter) or a list of strings (a combined parameter). For example, if you configure ``AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]``, axes will block clients by ip and/or username and user agent combination. See :ref:`customizing-lockout-parameters` for more details. |
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
**Common configurations**
.. code-block:: python
# Classic: 3 failures -> 30 min lockout
AXES_FAILURE_LIMIT = 3
AXES_COOLOFF_TIME = timedelta(minutes=30)
# Rolling window: max 5 failures in any 15-minute period
AXES_FAILURE_LIMIT = 5
AXES_COOLOFF_TIME = timedelta(minutes=15)
AXES_USE_ATTEMPT_EXPIRATION = True
# Hard lockout (manual reset only)
AXES_FAILURE_LIMIT = 5
AXES_COOLOFF_TIME = None
The configuration option precedences for the access attempt monitoring are: The configuration option precedences for the access attempt monitoring are:
1. Default: only use IP address. 1. Default: only use IP address.
@ -128,8 +109,6 @@ following settings to suit your set up to correctly resolve client IP addresses:
* ``AXES_IPWARE_META_PRECEDENCE_ORDER``: The names of ``request.META`` attributes as a tuple of strings * ``AXES_IPWARE_META_PRECEDENCE_ORDER``: The names of ``request.META`` attributes as a tuple of strings
to check to get the client IP address. Check the Django documentation for header naming conventions. to check to get the client IP address. Check the Django documentation for header naming conventions.
Default: ``IPWARE_META_PRECEDENCE_ORDER`` setting if set, else ``('REMOTE_ADDR', )`` Default: ``IPWARE_META_PRECEDENCE_ORDER`` setting if set, else ``('REMOTE_ADDR', )``
* ``AXES_IPWARE_PROXY_ORDER``: The order in which to evaluate IP addresses from proxy headers when multiple IPs are present
in the header chain. Must be either ``"left-most"`` or ``"right-most"``. **Default:** ``"left-most"``
.. note:: .. note::
For reverse proxies or e.g. Heroku, you might also want to fetch IP addresses from a HTTP header such as ``X-Forwarded-For``. To configure this, you can fetch IPs through the ``HTTP_X_FORWARDED_FOR`` key from the ``request.META`` property which contains all the HTTP headers in Django: For reverse proxies or e.g. Heroku, you might also want to fetch IP addresses from a HTTP header such as ``X-Forwarded-For``. To configure this, you can fetch IPs through the ``HTTP_X_FORWARDED_FOR`` key from the ``request.META`` property which contains all the HTTP headers in Django:

View file

@ -166,7 +166,7 @@ An example of usage could be e.g. a custom view for processing lockouts.
from django.http import JsonResponse from django.http import JsonResponse
def lockout(request, response, credentials, *args, **kwargs): def lockout(request, credentials, *args, **kwargs):
return JsonResponse({"status": "Locked out due to too many login failures"}, status=403) return JsonResponse({"status": "Locked out due to too many login failures"}, status=403)
``settings.py``:: ``settings.py``::
@ -188,7 +188,7 @@ Example ``AXES_LOCKOUT_PARAMETERS`` configuration:
AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]] AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]
This way, axes will lock out users using ip_address or combination of username and user_agent This way, axes will lock out users using ip_address and/or combination of username and user agent
Example of callable ``AXES_LOCKOUT_PARAMETERS``: Example of callable ``AXES_LOCKOUT_PARAMETERS``:
@ -213,7 +213,7 @@ Example of callable ``AXES_LOCKOUT_PARAMETERS``:
AXES_LOCKOUT_PARAMETERS = "example.utils.get_lockout_parameters" AXES_LOCKOUT_PARAMETERS = "example.utils.get_lockout_parameters"
This way, if client ip_address is localhost, axes will lockout client only by username. In other case, axes will lockout client by username or ip_address. This way, if client ip_address is localhost, axes will lockout client only by username. In other case, axes will lockout client by username and/or ip_address.
Customizing client ip address lookups Customizing client ip address lookups
------------------------------------- -------------------------------------

View file

@ -1,9 +0,0 @@
@import url("theme.css");
.wy-nav-content {
max-width: none;
}
.wy-table-responsive table td, .wy-table-responsive table th {
white-space: inherit;
}

View file

@ -6,8 +6,8 @@ More information on the configuration options is available at:
https://www.sphinx-doc.org/en/master/usage/configuration.html https://www.sphinx-doc.org/en/master/usage/configuration.html
""" """
# import sphinx_rtd_theme import sphinx_rtd_theme
from importlib.metadata import version as get_version from pkg_resources import get_distribution
import django import django
from django.conf import settings from django.conf import settings
@ -25,7 +25,7 @@ description = ("Keep track of failed login attempts in Django-powered sites.",)
# Add any Sphinx extension module names here, as strings. # Add any Sphinx extension module names here, as strings.
# They can be extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. # They can be extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = ["sphinx_rtd_theme","sphinx.ext.autodoc"] extensions = ["sphinx.ext.autodoc"]
# Add any paths that contain templates here, relative to this directory. # Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"] templates_path = ["_templates"]
@ -43,7 +43,7 @@ copyright = "2016, Jazzband"
author = "Jazzband" author = "Jazzband"
# The full version, including alpha/beta/rc tags. # The full version, including alpha/beta/rc tags.
release = get_version("django-axes") release = get_distribution("django-axes").version
# The short X.Y version. # The short X.Y version.
version = ".".join(release.split(".")[:2]) version = ".".join(release.split(".")[:2])
@ -71,10 +71,8 @@ todo_include_todos = False
# a list of builtin themes. # a list of builtin themes.
html_theme = "sphinx_rtd_theme" html_theme = "sphinx_rtd_theme"
html_style = "css/custom_theme.css"
# Add any paths that contain custom themes here, relative to this directory. # Add any paths that contain custom themes here, relative to this directory.
# html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
# Add any paths that contain custom static files (such as style sheets) here, # Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files, # relative to this directory. They are copied after the builtin static files,

View file

@ -1,5 +1,5 @@
[mypy] [mypy]
python_version = 3.14 python_version = 3.8
ignore_missing_imports = True ignore_missing_imports = True
[mypy-axes.migrations.*] [mypy-axes.migrations.*]

View file

@ -10,35 +10,36 @@ DJANGO_SETTINGS_MODULE = "tests.settings"
legacy_tox_ini = """ legacy_tox_ini = """
[tox] [tox]
envlist = envlist =
py{310,311,312}-dj42 py{38,39,310,py310}-dj32
py{310,311,312,313}-dj52 py{38,39,310,311,py310}-dj42
py{312,313,314}-dj60 py{310,311,py310}-dj50
py314-djmain py311-djmain
py314-djqa py311-djqa
[gh-actions] [gh-actions]
python = python =
3.8: py38
3.9: py39
3.10: py310 3.10: py310
3.11: py311 3.11: py311
3.12: py312 3.12: py312
3.13: py313 pypy-3.10: pypy310
3.14: py314
[gh-actions:env] [gh-actions:env]
DJANGO = DJANGO =
3.2: dj32
4.1: dj41
4.2: dj42 4.2: dj42
5.2: dj52
6.0: dj60
main: djmain main: djmain
qa: djqa qa: djqa
# Normal test environment runs pytest which orchestrates other tools # Normal test environment runs pytest which orchestrates other tools
[testenv] [testenv]
deps = deps =
-r requirements.txt -r requirements-test.txt
dj42: django>=4.2,<4.3 dj32: django>=3.2,<3.3
dj52: django>=5.2,<5.3 dj42: django>=4.1,<4.2
dj60: django>=6.0,<6.1 dj50: django>=5.0,<5.1
djmain: https://github.com/django/django/archive/main.tar.gz djmain: https://github.com/django/django/archive/main.tar.gz
usedevelop = true usedevelop = true
commands = pytest commands = pytest
@ -47,15 +48,16 @@ setenv =
# Django development version is allowed to fail the test matrix # Django development version is allowed to fail the test matrix
ignore_outcome = ignore_outcome =
djmain: True djmain: True
pypy310: True
ignore_errors = ignore_errors =
djmain: True djmain: True
pypy310: True
# QA runs type checks, linting, and code formatting checks # QA runs type checks, linting, and code formatting checks
[testenv:py314-djqa] [testenv:py312-djqa]
stoponfail = false deps = -r requirements-qa.txt
deps = -r requirements.txt
commands = commands =
mypy axes mypy axes
prospector axes prospector
black --check --diff axes black -t py38 --check --diff axes
""" """

3
requirements-qa.txt Normal file
View file

@ -0,0 +1,3 @@
black==24.4.2
mypy==1.10.0
prospector==1.10.3

7
requirements-test.txt Normal file
View file

@ -0,0 +1,7 @@
-e .
django-ipware>=3
coverage==7.5.3
pytest==8.2.2
pytest-cov==5.0.0
pytest-django==4.8.0
pytest-subtests==0.12.1

View file

@ -1,12 +1,5 @@
-e . -e .
black==26.3.1 -r requirements-qa.txt
coverage==7.13.5 -r requirements-test.txt
django-ipware>=3 sphinx_rtd_theme==2.0.0
mypy==1.19.1 tox==4.15.1
prospector==1.18.0
pytest-cov==7.0.0
pytest-django==4.12.0
pytest-subtests==0.15.0
pytest==9.0.2
sphinx_rtd_theme==3.1.0
tox==4.50.1

View file

@ -35,9 +35,9 @@ setup(
package_dir={"axes": "axes"}, package_dir={"axes": "axes"},
use_scm_version=True, use_scm_version=True,
setup_requires=["setuptools_scm"], setup_requires=["setuptools_scm"],
python_requires=">=3.10", python_requires=">=3.7",
install_requires=[ install_requires=[
"django>=4.2", "django>=3.2",
"asgiref>=3.6.0", "asgiref>=3.6.0",
], ],
extras_require={ extras_require={
@ -50,21 +50,22 @@ setup(
"Environment :: Web Environment", "Environment :: Web Environment",
"Environment :: Plugins", "Environment :: Plugins",
"Framework :: Django", "Framework :: Django",
"Framework :: Django :: 3.2",
"Framework :: Django :: 4.2", "Framework :: Django :: 4.2",
"Framework :: Django :: 5.2", "Framework :: Django :: 5.0",
"Framework :: Django :: 6.0",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"Intended Audience :: System Administrators", "Intended Audience :: System Administrators",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Programming Language :: Python", "Programming Language :: Python",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
"Topic :: Internet :: Log Analysis", "Topic :: Internet :: Log Analysis",
"Topic :: Security", "Topic :: Security",
"Topic :: System :: Logging", "Topic :: System :: Logging",

View file

@ -3,7 +3,6 @@ from string import ascii_letters, digits
from time import sleep from time import sleep
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.base_user import AbstractBaseUser
from django.http import HttpRequest from django.http import HttpRequest
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse

View file

@ -1,7 +1,7 @@
from unittest.mock import patch from unittest.mock import patch
from django.http import HttpRequest from django.http import HttpRequest
from django.test import override_settings, RequestFactory from django.test import override_settings
from django.utils.timezone import now from django.utils.timezone import now
from axes.attempts import get_cool_off_threshold from axes.attempts import get_cool_off_threshold
@ -15,13 +15,12 @@ class GetCoolOffThresholdTestCase(AxesTestCase):
def test_get_cool_off_threshold(self): def test_get_cool_off_threshold(self):
timestamp = now() timestamp = now()
request = RequestFactory().post("/")
with patch("axes.attempts.now", return_value=timestamp): with patch("axes.attempts.now", return_value=timestamp):
request.axes_attempt_time = timestamp attempt_time = timestamp
threshold_now = get_cool_off_threshold(request) threshold_now = get_cool_off_threshold(attempt_time)
request.axes_attempt_time = None attempt_time = None
threshold_none = get_cool_off_threshold(request) threshold_none = get_cool_off_threshold(attempt_time)
self.assertEqual(threshold_now, threshold_none) self.assertEqual(threshold_now, threshold_none)

View file

@ -129,24 +129,3 @@ class ConfCheckTestCase(AxesTestCase):
def test_valid_callable(self): def test_valid_callable(self):
warnings = run_checks() warnings = run_checks()
self.assertEqual(warnings, []) self.assertEqual(warnings, [])
def test_missing_settings_no_error(self):
warnings = run_checks()
self.assertEqual(warnings, [])
class LockoutParametersCheckTestCase(AxesTestCase):
@override_settings(AXES_LOCKOUT_PARAMETERS=["ip_address", "username"])
def test_valid_configuration(self):
warnings = run_checks()
self.assertEqual(warnings, [])
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "user_agent"])
def test_invalid_configuration(self):
warnings = run_checks()
warning = Warning(
msg=Messages.LOCKOUT_PARAMETERS_INVALID,
hint=Hints.LOCKOUT_PARAMETERS_INVALID,
id=Codes.LOCKOUT_PARAMETERS_INVALID,
)
self.assertEqual(warnings, [warning])

View file

@ -1,45 +0,0 @@
from django.test import TestCase
from django.utils.functional import SimpleLazyObject
class ConfTestCase(TestCase):
def test_axes_username_form_field_uses_lazy_evaluation(self):
"""
Test that AXES_USERNAME_FORM_FIELD uses SimpleLazyObject for lazy evaluation.
This prevents circular import issues with custom user models (issue #1280).
"""
from axes.conf import settings
# Verify that AXES_USERNAME_FORM_FIELD is a SimpleLazyObject if not overridden
# This is only the case when the setting is not explicitly defined
username_field = settings.AXES_USERNAME_FORM_FIELD
# The actual type depends on whether AXES_USERNAME_FORM_FIELD was overridden
# If it's using the default, it should be a SimpleLazyObject
# If overridden in settings, it could be a plain string
# Either way, it should be usable as a string
# Force evaluation and verify it works
username_field_str = str(username_field)
# Should get the default USERNAME_FIELD from the user model
# For the test suite, this is "username"
self.assertIsInstance(username_field_str, str)
self.assertTrue(len(username_field_str) > 0)
def test_axes_username_form_field_evaluates_correctly(self):
"""
Test that when AXES_USERNAME_FORM_FIELD is accessed, it correctly
resolves to the user model's USERNAME_FIELD.
"""
from django.contrib.auth import get_user_model
from axes.conf import settings
# Get the expected value
expected_username_field = get_user_model().USERNAME_FIELD
# Get the actual value from axes settings
actual_username_field = str(settings.AXES_USERNAME_FORM_FIELD)
# They should match
self.assertEqual(actual_username_field, expected_username_field)

View file

@ -1,20 +1,18 @@
from platform import python_implementation from platform import python_implementation
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from datetime import datetime, timezone as dt_timezone
from django.test import override_settings
from django.utils import timezone
from axes.handlers.database import AxesDatabaseHandler
from axes.models import AccessAttempt, AccessLog, AccessFailureLog, AccessAttemptExpiration
from pytest import mark from pytest import mark
from django.core.cache import cache from django.core.cache import cache
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from django.utils.timezone import timedelta from django.utils.timezone import timedelta
from axes.conf import settings from axes.conf import settings
from axes.handlers.proxy import AxesProxyHandler from axes.handlers.proxy import AxesProxyHandler
from axes.helpers import get_client_str from axes.helpers import get_client_str
from axes.models import AccessAttempt, AccessLog, AccessFailureLog
from tests.base import AxesTestCase from tests.base import AxesTestCase
@ -238,6 +236,11 @@ class ResetAttemptsTestCase(AxesHandlerBaseTestCase):
AXES_RESET_ON_SUCCESS=True, AXES_RESET_ON_SUCCESS=True,
AXES_ENABLE_ACCESS_FAILURE_LOG=True, AXES_ENABLE_ACCESS_FAILURE_LOG=True,
) )
@mark.xfail(
python_implementation() == "PyPy",
reason="PyPy implementation is flaky for this test",
strict=False,
)
class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase): class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase):
def test_handler_reset_attempts(self): def test_handler_reset_attempts(self):
self.create_attempt() self.create_attempt()
@ -569,170 +572,3 @@ class AxesTestHandlerTestCase(AxesHandlerBaseTestCase):
def test_handler_get_failures(self): def test_handler_get_failures(self):
self.assertEqual(0, AxesProxyHandler.get_failures(self.request, {})) self.assertEqual(0, AxesProxyHandler.get_failures(self.request, {}))
@override_settings(AXES_HANDLER="axes.handlers.database.AxesDatabaseHandler", AXES_COOLOFF_TIME=timezone.timedelta(seconds=10))
class AxesDatabaseHandlerExpirationFlagTestCase(AxesTestCase):
def setUp(self):
super().setUp()
self.handler = AxesDatabaseHandler()
self.mock_request = MagicMock()
self.mock_credentials = None
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=True)
@patch("axes.handlers.database.log")
@patch("axes.models.AccessAttempt.objects.filter")
@patch("django.utils.timezone.now")
def test_clean_expired_user_attempts_expiration_true(self, mock_now, mock_filter, mock_log):
mock_now.return_value = datetime(2025, 1, 1, tzinfo=dt_timezone.utc)
mock_qs = MagicMock()
mock_filter.return_value = mock_qs
mock_qs.delete.return_value = (3, None)
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
mock_filter.assert_called_once_with(expiration__expires_at__lte=mock_now.return_value)
mock_qs.delete.assert_called_once()
mock_log.info.assert_called_with(
"AXES: Cleaned up %s expired access attempts from database that expiry were older than %s",
3,
mock_now.return_value,
)
self.assertEqual(count, 3)
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=True)
@patch("axes.handlers.database.log")
def test_clean_expired_user_attempts_expiration_true_with_complete_deletion(self, mock_log):
AccessAttempt.objects.all().delete()
dummy_attempt = AccessAttempt.objects.create(
username="test_user",
ip_address="192.168.1.1",
failures_since_start=1,
user_agent="test_agent",
)
dummy_attempt.expiration = AccessAttemptExpiration.objects.create(
access_attempt=dummy_attempt,
expires_at=timezone.now() - timezone.timedelta(days=1) # Set to expire in the past
)
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
mock_log.info.assert_called_once()
# comparing count=2, as one is the dummy attempt and one is the expiration
self.assertEqual(count, 2)
self.assertEqual(
AccessAttempt.objects.count(), 0
)
self.assertEqual(
AccessAttemptExpiration.objects.count(), 0
)
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=True)
@patch("axes.handlers.database.log")
def test_clean_expired_user_attempts_expiration_true_with_partial_deletion(self, mock_log):
attempt_not_expired = AccessAttempt.objects.create(
username="test_user",
ip_address="192.168.1.1",
failures_since_start=1,
user_agent="test_agent",
)
attempt_not_expired.expiration = AccessAttemptExpiration.objects.create(
access_attempt=attempt_not_expired,
expires_at=timezone.now() + timezone.timedelta(days=1) # Set to expire in the future
)
attempt_expired = AccessAttempt.objects.create(
username="test_user_2",
ip_address="192.168.1.2",
failures_since_start=1,
user_agent="test_agent",
)
attempt_expired.expiration = AccessAttemptExpiration.objects.create(
access_attempt=attempt_expired,
expires_at=timezone.now() - timezone.timedelta(days=1) # Set to expire in the past
)
access_attempt_count = AccessAttempt.objects.count()
access_attempt_expiration_count = AccessAttemptExpiration.objects.count()
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
mock_log.info.assert_called_once()
# comparing count=2, as one is the dummy attempt and one is the expiration
self.assertEqual(count, 2)
self.assertEqual(
AccessAttempt.objects.count(), access_attempt_count - 1
)
self.assertEqual(
AccessAttemptExpiration.objects.count(), access_attempt_expiration_count - 1
)
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=True)
@patch("axes.handlers.database.log")
def test_clean_expired_user_attempts_expiration_true_with_no_deletion(self, mock_log):
attempt_not_expired_1 = AccessAttempt.objects.create(
username="test_user",
ip_address="192.168.1.1",
failures_since_start=1,
user_agent="test_agent",
)
attempt_not_expired_1.expiration = AccessAttemptExpiration.objects.create(
access_attempt=attempt_not_expired_1,
expires_at=timezone.now() + timezone.timedelta(days=1) # Set to expire in the future
)
attempt_not_expired_2 = AccessAttempt.objects.create(
username="test_user_2",
ip_address="192.168.1.2",
failures_since_start=1,
user_agent="test_agent",
)
attempt_not_expired_2.expiration = AccessAttemptExpiration.objects.create(
access_attempt=attempt_not_expired_2,
expires_at=timezone.now() + timezone.timedelta(days=2) # Set to expire in the future
)
access_attempt_count = AccessAttempt.objects.count()
access_attempt_expiration_count = AccessAttemptExpiration.objects.count()
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
mock_log.info.assert_called_once()
# comparing count=2, as one is the dummy attempt and one is the expiration
self.assertEqual(count, 0)
self.assertEqual(
AccessAttempt.objects.count(), access_attempt_count
)
self.assertEqual(
AccessAttemptExpiration.objects.count(), access_attempt_expiration_count
)
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=False)
@patch("axes.handlers.database.log")
@patch("axes.handlers.database.get_cool_off_threshold")
@patch("axes.models.AccessAttempt.objects.filter")
def test_clean_expired_user_attempts_expiration_false(self, mock_filter, mock_get_threshold, mock_log):
mock_get_threshold.return_value = "fake-threshold"
mock_qs = MagicMock()
mock_filter.return_value = mock_qs
mock_qs.delete.return_value = (2, None)
count = self.handler.clean_expired_user_attempts(request=self.mock_request, credentials=None)
mock_filter.assert_called_once_with(attempt_time__lte="fake-threshold")
mock_qs.delete.assert_called_once()
mock_log.info.assert_called_with(
"AXES: Cleaned up %s expired access attempts from database that were older than %s",
2,
"fake-threshold",
)
self.assertEqual(count, 2)
@override_settings(AXES_COOLOFF_TIME=None)
@patch("axes.handlers.database.log")
def test_clean_expired_user_attempts_no_cooloff(self, mock_log):
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
mock_log.debug.assert_called_with(
"AXES: Skipping clean for expired access attempts because no AXES_COOLOFF_TIME is configured"
)
self.assertEqual(count, 0)

View file

@ -59,38 +59,6 @@ class CacheTestCase(AxesTestCase):
def test_get_cache_timeout_none(self): def test_get_cache_timeout_none(self):
self.assertEqual(get_cache_timeout(), None) self.assertEqual(get_cache_timeout(), None)
def test_get_increasing_cache_timeout_by_username(self):
user_durations = {
"ben": timedelta(minutes=5),
"jen": timedelta(minutes=10),
}
def _callback(request):
username = request.POST["username"] if request else object()
previous_duration = user_durations.get(username, timedelta())
user_durations[username] = previous_duration + timedelta(minutes=5)
return user_durations[username]
rf = RequestFactory()
ben_req = rf.post("/", data={"username": "ben"})
jen_req = rf.post("/", data={"username": "jen"})
james_req = rf.post("/", data={"username": "james"})
with override_settings(AXES_COOLOFF_TIME=_callback):
with self.subTest("no username"):
self.assertEqual(get_cache_timeout(), 300)
with self.subTest("ben"):
self.assertEqual(get_cache_timeout(ben_req), 600)
self.assertEqual(get_cache_timeout(ben_req), 900)
self.assertEqual(get_cache_timeout(ben_req), 1200)
with self.subTest("jen"):
self.assertEqual(get_cache_timeout(jen_req), 900)
with self.subTest("james"):
self.assertEqual(get_cache_timeout(james_req), 300)
class TimestampTestCase(AxesTestCase): class TimestampTestCase(AxesTestCase):
def test_iso8601(self): def test_iso8601(self):
@ -947,7 +915,7 @@ class LockoutResponseTestCase(AxesTestCase):
self.assertEqual(type(response), HttpResponse) self.assertEqual(type(response), HttpResponse)
def mock_get_cool_off_str(req): def mock_get_cool_off_str():
return timedelta(seconds=30) return timedelta(seconds=30)
@ -972,7 +940,7 @@ class AxesCoolOffTestCase(AxesTestCase):
def test_get_cool_off_float_gt_0(self): def test_get_cool_off_float_gt_0(self):
self.assertEqual(get_cool_off(), timedelta(seconds=6120)) self.assertEqual(get_cool_off(), timedelta(seconds=6120))
@override_settings(AXES_COOLOFF_TIME=lambda r: timedelta(seconds=30)) @override_settings(AXES_COOLOFF_TIME=lambda: timedelta(seconds=30))
def test_get_cool_off_callable(self): def test_get_cool_off_callable(self):
self.assertEqual(get_cool_off(), timedelta(seconds=30)) self.assertEqual(get_cool_off(), timedelta(seconds=30))
@ -1013,16 +981,9 @@ def mock_get_lockout_response(request, credentials):
return HttpResponse(status=400) return HttpResponse(status=400)
def mock_get_lockout_response_with_original_response_param(
request, response, credentials
):
return HttpResponse(status=400)
class AxesLockoutTestCase(AxesTestCase): class AxesLockoutTestCase(AxesTestCase):
def setUp(self): def setUp(self):
self.request = HttpRequest() self.request = HttpRequest()
self.response = HttpResponse()
self.credentials = dict() self.credentials = dict()
def test_get_lockout_response(self): def test_get_lockout_response(self):
@ -1046,20 +1007,6 @@ class AxesLockoutTestCase(AxesTestCase):
response = get_lockout_response(self.request, self.credentials) response = get_lockout_response(self.request, self.credentials)
self.assertEqual(400, response.status_code) self.assertEqual(400, response.status_code)
@override_settings(
AXES_LOCKOUT_CALLABLE=mock_get_lockout_response_with_original_response_param
)
def test_get_lockout_response_override_callable_with_original_response_param(self):
response = get_lockout_response(self.request, self.response, self.credentials)
self.assertEqual(400, response.status_code)
@override_settings(
AXES_LOCKOUT_CALLABLE="tests.test_helpers.mock_get_lockout_response_with_original_response_param"
)
def test_get_lockout_response_override_path_with_original_response_param(self):
response = get_lockout_response(self.request, self.response, self.credentials)
self.assertEqual(400, response.status_code)
@override_settings(AXES_LOCKOUT_CALLABLE=42) @override_settings(AXES_LOCKOUT_CALLABLE=42)
def test_get_lockout_response_override_invalid(self): def test_get_lockout_response_override_invalid(self):
with self.assertRaises(TypeError): with self.assertRaises(TypeError):

View file

@ -81,7 +81,7 @@ class AccessLogTestCase(AxesTestCase):
""" """
# An impossibly large post dict # An impossibly large post dict
extra_data = {"too-large-field": "x" * 2 ** 16} extra_data = {"a" * x: x for x in range(1024)}
self.login(**extra_data) self.login(**extra_data)
self.assertEqual(len(AccessAttempt.objects.latest("id").post_data), 1024) self.assertEqual(len(AccessAttempt.objects.latest("id").post_data), 1024)