mirror of
https://github.com/jazzband/django-axes.git
synced 2026-03-17 06:40:24 +00:00
Compare commits
No commits in common. "master" and "6.5.2" have entirely different histories.
35 changed files with 212 additions and 967 deletions
8
.github/workflows/codeql.yml
vendored
8
.github/workflows/codeql.yml
vendored
|
|
@ -14,11 +14,11 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- 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
|
||||
# with:
|
||||
# languages: go, javascript, csharp, python, cpp, java
|
||||
|
|
@ -26,7 +26,7 @@ jobs:
|
|||
# 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).
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# ℹ️ 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
|
||||
|
|
@ -40,4 +40,4 @@ jobs:
|
|||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@v3
|
||||
|
|
|
|||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -14,14 +14,14 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
python-version: 3.8
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
|
|
|
|||
29
.github/workflows/test.yml
vendored
29
.github/workflows/test.yml
vendored
|
|
@ -11,32 +11,33 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 5
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
||||
django-version: ['4.2', '5.2', '6.0']
|
||||
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
|
||||
django-version: ['4.2', '5.0', '5.1']
|
||||
include:
|
||||
# Tox configuration for QA environment
|
||||
- python-version: '3.14'
|
||||
- python-version: '3.12'
|
||||
django-version: 'qa'
|
||||
# Django main
|
||||
- python-version: '3.14'
|
||||
- python-version: '3.12'
|
||||
django-version: 'main'
|
||||
experimental: true
|
||||
exclude:
|
||||
- python-version: '3.13'
|
||||
django-version: '4.2'
|
||||
- python-version: '3.8'
|
||||
django-version: '5.0'
|
||||
- python-version: '3.9'
|
||||
django-version: '5.2'
|
||||
- python-version: '3.10'
|
||||
django-version: '6.0'
|
||||
- python-version: '3.11'
|
||||
django-version: '6.0'
|
||||
django-version: '5.0'
|
||||
- python-version: '3.8'
|
||||
django-version: '5.1'
|
||||
- python-version: '3.9'
|
||||
django-version: '5.1'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
|
|
@ -46,7 +47,7 @@ jobs:
|
|||
echo "::set-output name=dir::$(pip cache dir)"
|
||||
|
||||
- name: Cache
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pip-cache.outputs.dir }}
|
||||
key:
|
||||
|
|
|
|||
82
CHANGES.rst
82
CHANGES.rst
|
|
@ -2,88 +2,6 @@
|
|||
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)
|
||||
------------------
|
||||
|
|
|
|||
|
|
@ -4,59 +4,26 @@ from django.utils.translation import gettext_lazy as _
|
|||
|
||||
from axes.conf import settings
|
||||
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):
|
||||
list_display = [
|
||||
list_display = (
|
||||
"attempt_time",
|
||||
"ip_address",
|
||||
"user_agent",
|
||||
"username",
|
||||
"path_info",
|
||||
"failures_since_start",
|
||||
]
|
||||
|
||||
if settings.AXES_USE_ATTEMPT_EXPIRATION:
|
||||
list_display.append("expiration")
|
||||
)
|
||||
|
||||
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"]
|
||||
|
||||
date_hierarchy = "attempt_time"
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{"fields": ("username", "path_info", "failures_since_start", "expiration")},
|
||||
),
|
||||
(None, {"fields": ("username", "path_info", "failures_since_start")}),
|
||||
(_("Form Data"), {"fields": ("get_data", "post_data")}),
|
||||
(_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
|
||||
)
|
||||
|
|
@ -71,34 +38,11 @@ class AccessAttemptAdmin(admin.ModelAdmin):
|
|||
"get_data",
|
||||
"post_data",
|
||||
"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:
|
||||
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):
|
||||
list_display = (
|
||||
|
|
|
|||
|
|
@ -1,26 +1,106 @@
|
|||
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.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__)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
cool_off = get_cool_off(request)
|
||||
cool_off = get_cool_off()
|
||||
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 now() - 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
|
||||
|
|
|
|||
|
|
@ -22,10 +22,6 @@ class Messages:
|
|||
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"
|
||||
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:
|
||||
|
|
@ -34,7 +30,6 @@ class Hints:
|
|||
BACKEND_INVALID = "AxesModelBackend was renamed to AxesStandaloneBackend in django-axes version 5.0."
|
||||
SETTING_DEPRECATED = None
|
||||
CALLABLE_INVALID = None
|
||||
LOCKOUT_PARAMETERS_INVALID = "Add 'ip_address' to AXES_LOCKOUT_PARAMETERS."
|
||||
|
||||
|
||||
class Codes:
|
||||
|
|
@ -43,7 +38,6 @@ class Codes:
|
|||
BACKEND_INVALID = "axes.W003"
|
||||
SETTING_DEPRECATED = "axes.W004"
|
||||
CALLABLE_INVALID = "axes.W005"
|
||||
LOCKOUT_PARAMETERS_INVALID = "axes.W006"
|
||||
|
||||
|
||||
@register(Tags.security, Tags.caches, Tags.compatibility)
|
||||
|
|
@ -164,34 +158,6 @@ def axes_deprecation_check(app_configs, **kwargs): # pylint: disable=unused-arg
|
|||
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
|
||||
def axes_conf_check(app_configs, **kwargs): # pylint: disable=unused-argument
|
||||
warnings = []
|
||||
|
|
@ -207,7 +173,7 @@ def axes_conf_check(app_configs, **kwargs): # pylint: disable=unused-argument
|
|||
]
|
||||
|
||||
for callable_setting in callable_settings:
|
||||
value = getattr(settings, callable_setting, None)
|
||||
value = getattr(settings, callable_setting)
|
||||
if not is_valid_callable(value):
|
||||
warnings.append(
|
||||
Warning(
|
||||
|
|
|
|||
28
axes/conf.py
28
axes/conf.py
|
|
@ -1,21 +1,6 @@
|
|||
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 _
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
settings.AXES_ENABLE_ADMIN = getattr(settings, "AXES_ENABLE_ADMIN", True)
|
||||
|
||||
|
||||
# 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",
|
||||
JSONSerializableLazyObject(_get_username_field_default),
|
||||
settings, "AXES_USERNAME_FORM_FIELD", "username"
|
||||
)
|
||||
|
||||
# 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_USE_ATTEMPT_EXPIRATION = getattr(
|
||||
settings, "AXES_USE_ATTEMPT_EXPIRATION", False
|
||||
)
|
||||
|
||||
settings.AXES_VERBOSE = getattr(settings, "AXES_VERBOSE", settings.AXES_ENABLED)
|
||||
|
||||
# whitelist and blacklist
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
return
|
||||
|
||||
cache_keys = get_client_cache_keys(request, credentials)
|
||||
cache_timeout = get_cache_timeout(request)
|
||||
cache_timeout = get_cache_timeout()
|
||||
failures = []
|
||||
for cache_key in cache_keys:
|
||||
added = self.cache.add(key=cache_key, value=1, timeout=cache_timeout)
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
from logging import getLogger
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
|
||||
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.http import HttpRequest
|
||||
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.handlers.base import AbstractAxesHandler, AxesBaseHandler
|
||||
from axes.handlers.base import AxesBaseHandler, AbstractAxesHandler
|
||||
from axes.helpers import (
|
||||
get_client_parameters,
|
||||
get_client_session_hash,
|
||||
get_client_str,
|
||||
get_client_username,
|
||||
|
|
@ -19,14 +21,8 @@ from axes.helpers import (
|
|||
get_failure_limit,
|
||||
get_lockout_parameters,
|
||||
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
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
|
@ -108,7 +104,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
return count
|
||||
|
||||
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(
|
||||
(
|
||||
attempts.aggregate(Sum("failures_since_start"))[
|
||||
|
|
@ -121,10 +117,10 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
return attempt_count
|
||||
|
||||
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
|
||||
lockout attribute and emit lockout signal.
|
||||
|
||||
"""
|
||||
|
||||
log.info("AXES: User login failed, running database handler for failure.")
|
||||
|
|
@ -136,7 +132,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
return
|
||||
|
||||
# 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)
|
||||
client_str = get_client_str(
|
||||
|
|
@ -225,23 +221,6 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
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
|
||||
failures_since_start = self.get_failures(request, credentials)
|
||||
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.
|
||||
"""
|
||||
|
||||
# 1. database query: Clean up expired user attempts from the database
|
||||
clean_expired_user_attempts(request.axes_attempt_time)
|
||||
|
||||
username = user.get_username()
|
||||
credentials = get_credentials(username)
|
||||
client_str = get_client_str(
|
||||
|
|
@ -294,9 +276,6 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
|
||||
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:
|
||||
# 2. database query: Insert new access logs with login time
|
||||
AccessLog.objects.create(
|
||||
|
|
@ -313,7 +292,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
|
||||
if settings.AXES_RESET_ON_SUCCESS:
|
||||
# 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(
|
||||
"AXES: Deleted %d failed login attempts by %s from database.",
|
||||
count,
|
||||
|
|
@ -325,8 +304,10 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
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
|
||||
credentials = get_credentials(username) if username else None
|
||||
client_str = get_client_str(
|
||||
username,
|
||||
request.axes_ip_address,
|
||||
|
|
@ -335,9 +316,6 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
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)
|
||||
|
||||
if username and not settings.AXES_DISABLE_ACCESS_LOG:
|
||||
|
|
@ -349,103 +327,6 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
session_hash=get_client_session_hash(request),
|
||||
).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):
|
||||
"""
|
||||
Handles the ``axes.models.AccessAttempt`` object post save signal.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from datetime import timedelta, datetime
|
||||
from datetime import timedelta
|
||||
from hashlib import sha256
|
||||
from logging import getLogger
|
||||
from string import Template
|
||||
|
|
@ -32,7 +32,7 @@ def get_cache() -> BaseCache:
|
|||
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.
|
||||
|
||||
|
|
@ -43,22 +43,21 @@ def get_cache_timeout(request: Optional[HttpRequest] = None) -> Optional[int]:
|
|||
for use with the Django cache backends.
|
||||
"""
|
||||
|
||||
cool_off = get_cool_off(request)
|
||||
cool_off = get_cool_off()
|
||||
if cool_off is None:
|
||||
return None
|
||||
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.
|
||||
|
||||
The return value is either None or timedelta.
|
||||
|
||||
Notice that the settings.AXES_COOLOFF_TIME is either None, timedelta, integer/float of hours,
|
||||
a path to a callable or a callable taking 1 argument (the request). This function
|
||||
offers a unified _timedelta or None_ representation of that configuration for use with the
|
||||
Axes internal implementations.
|
||||
Notice that the settings.AXES_COOLOFF_TIME is either None, timedelta, or integer/float of hours,
|
||||
and this function offers a unified _timedelta or None_ representation of that configuration
|
||||
for use with the Axes internal implementations.
|
||||
|
||||
: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):
|
||||
return timedelta(minutes=cool_off * 60)
|
||||
if isinstance(cool_off, str):
|
||||
cool_off_func = import_string(cool_off)
|
||||
return cool_off_func(request)
|
||||
return import_string(cool_off)()
|
||||
if callable(cool_off):
|
||||
return cool_off(request) # pylint: disable=not-callable
|
||||
return cool_off() # pylint: disable=not-callable
|
||||
|
||||
return cool_off
|
||||
|
||||
|
|
@ -101,23 +99,6 @@ def get_cool_off_iso8601(delta: timedelta) -> 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:
|
||||
"""
|
||||
Calculate credentials for Axes to use internally from given username and kwargs.
|
||||
|
|
@ -164,7 +145,7 @@ def get_client_username(
|
|||
log.debug(
|
||||
"Using parameter credentials to get username with key settings.AXES_USERNAME_FORM_FIELD"
|
||||
)
|
||||
return credentials.get(settings.AXES_USERNAME_FORM_FIELD, None) # type: ignore[return-value]
|
||||
return credentials.get(settings.AXES_USERNAME_FORM_FIELD, None)
|
||||
|
||||
log.debug(
|
||||
"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(
|
||||
request: HttpRequest,
|
||||
original_response: Optional[HttpResponse] = None,
|
||||
credentials: Optional[dict] = None,
|
||||
request: HttpRequest, credentials: Optional[dict] = None
|
||||
) -> HttpResponse:
|
||||
if settings.AXES_LOCKOUT_CALLABLE:
|
||||
if callable(settings.AXES_LOCKOUT_CALLABLE):
|
||||
# Try calling with 3 args, fallback to 2 for backward compatibility
|
||||
try:
|
||||
return settings.AXES_LOCKOUT_CALLABLE(
|
||||
request, original_response, credentials
|
||||
)
|
||||
except TypeError:
|
||||
# Fallback: old signature without original_response
|
||||
return settings.AXES_LOCKOUT_CALLABLE(request, credentials)
|
||||
return settings.AXES_LOCKOUT_CALLABLE( # pylint: disable=not-callable
|
||||
request, credentials
|
||||
)
|
||||
if isinstance(settings.AXES_LOCKOUT_CALLABLE, str):
|
||||
callable_obj = import_string(settings.AXES_LOCKOUT_CALLABLE)
|
||||
# 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)
|
||||
return import_string(settings.AXES_LOCKOUT_CALLABLE)(request, credentials)
|
||||
raise TypeError(
|
||||
"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 "",
|
||||
}
|
||||
|
||||
cool_off = get_cool_off(request)
|
||||
cool_off = get_cool_off()
|
||||
if cool_off:
|
||||
context.update(
|
||||
{
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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.
|
|
@ -48,7 +48,7 @@ class AxesMiddleware:
|
|||
if settings.AXES_ENABLED:
|
||||
if getattr(request, "axes_locked_out", 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -51,29 +51,9 @@ class AccessAttempt(AccessBase):
|
|||
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):
|
||||
logout_time = models.DateTimeField(_("Logout Time"), null=True, blank=True)
|
||||
session_hash = models.CharField(
|
||||
_("Session key hash (sha256)"), default="", blank=True, max_length=64
|
||||
)
|
||||
session_hash = models.CharField(_("Session key hash (sha256)"), default="", blank=True, max_length=64)
|
||||
|
||||
def __str__(self):
|
||||
return f"Access Log for {self.username} @ {self.attempt_time}"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
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
|
||||
`GitHub <https://github.com/jazzband/django-axes/>`_ and see the
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
# If you do not want Axes to override the authentication response
|
||||
# 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',
|
||||
]
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
|
||||
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
|
||||
-------------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ The following ``settings.py`` options are available for customizing Axes behavio
|
|||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| 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 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 the request as argument. 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_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_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. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
|
|
@ -47,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_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_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. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
|
|
@ -109,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
|
||||
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', )``
|
||||
* ``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::
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ An example of usage could be e.g. a custom view for processing lockouts.
|
|||
|
||||
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)
|
||||
|
||||
``settings.py``::
|
||||
|
|
@ -188,7 +188,7 @@ Example ``AXES_LOCKOUT_PARAMETERS`` configuration:
|
|||
|
||||
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``:
|
||||
|
||||
|
|
@ -213,7 +213,7 @@ Example of callable ``AXES_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
|
||||
-------------------------------------
|
||||
|
|
|
|||
9
docs/_static/css/custom_theme.css
vendored
9
docs/_static/css/custom_theme.css
vendored
|
|
@ -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;
|
||||
}
|
||||
12
docs/conf.py
12
docs/conf.py
|
|
@ -6,8 +6,8 @@ More information on the configuration options is available at:
|
|||
https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
"""
|
||||
|
||||
# import sphinx_rtd_theme
|
||||
from importlib.metadata import version as get_version
|
||||
import sphinx_rtd_theme
|
||||
from pkg_resources import get_distribution
|
||||
|
||||
import django
|
||||
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.
|
||||
# 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.
|
||||
templates_path = ["_templates"]
|
||||
|
|
@ -43,7 +43,7 @@ copyright = "2016, Jazzband"
|
|||
author = "Jazzband"
|
||||
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = get_version("django-axes")
|
||||
release = get_distribution("django-axes").version
|
||||
|
||||
# The short X.Y version.
|
||||
version = ".".join(release.split(".")[:2])
|
||||
|
|
@ -71,10 +71,8 @@ todo_include_todos = False
|
|||
# a list of builtin themes.
|
||||
html_theme = "sphinx_rtd_theme"
|
||||
|
||||
html_style = "css/custom_theme.css"
|
||||
|
||||
# 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,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
|
|
|
|||
2
mypy.ini
2
mypy.ini
|
|
@ -1,5 +1,5 @@
|
|||
[mypy]
|
||||
python_version = 3.14
|
||||
python_version = 3.8
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-axes.migrations.*]
|
||||
|
|
|
|||
|
|
@ -10,35 +10,35 @@ DJANGO_SETTINGS_MODULE = "tests.settings"
|
|||
legacy_tox_ini = """
|
||||
[tox]
|
||||
envlist =
|
||||
py{310,311,312}-dj42
|
||||
py{310,311,312,313}-dj52
|
||||
py{312,313,314}-dj60
|
||||
py314-djmain
|
||||
py314-djqa
|
||||
py{38,39,310,311,312}-dj42
|
||||
py{310,311,312}-dj50
|
||||
py{310,311,312}-dj51
|
||||
py311-djmain
|
||||
py311-djqa
|
||||
|
||||
[gh-actions]
|
||||
python =
|
||||
3.8: py38
|
||||
3.9: py39
|
||||
3.10: py310
|
||||
3.11: py311
|
||||
3.12: py312
|
||||
3.13: py313
|
||||
3.14: py314
|
||||
|
||||
[gh-actions:env]
|
||||
DJANGO =
|
||||
4.2: dj42
|
||||
5.2: dj52
|
||||
6.0: dj60
|
||||
5.0: dj50
|
||||
5.1: dj51
|
||||
main: djmain
|
||||
qa: djqa
|
||||
|
||||
# Normal test environment runs pytest which orchestrates other tools
|
||||
[testenv]
|
||||
deps =
|
||||
-r requirements.txt
|
||||
dj42: django>=4.2,<4.3
|
||||
dj52: django>=5.2,<5.3
|
||||
dj60: django>=6.0,<6.1
|
||||
-r requirements-test.txt
|
||||
dj32: django>=3.2,<3.3
|
||||
dj42: django>=4.1,<4.2
|
||||
dj50: django>=5.0,<5.1
|
||||
djmain: https://github.com/django/django/archive/main.tar.gz
|
||||
usedevelop = true
|
||||
commands = pytest
|
||||
|
|
@ -51,11 +51,10 @@ ignore_errors =
|
|||
djmain: True
|
||||
|
||||
# QA runs type checks, linting, and code formatting checks
|
||||
[testenv:py314-djqa]
|
||||
stoponfail = false
|
||||
deps = -r requirements.txt
|
||||
[testenv:py312-djqa]
|
||||
deps = -r requirements-qa.txt
|
||||
commands =
|
||||
mypy axes
|
||||
prospector axes
|
||||
black --check --diff axes
|
||||
prospector
|
||||
black -t py38 --check --diff axes
|
||||
"""
|
||||
|
|
|
|||
3
requirements-qa.txt
Normal file
3
requirements-qa.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
black==24.8.0
|
||||
mypy==1.11.2
|
||||
prospector==1.10.3
|
||||
7
requirements-test.txt
Normal file
7
requirements-test.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
-e .
|
||||
django-ipware>=3
|
||||
coverage==7.6.1
|
||||
pytest==8.3.3
|
||||
pytest-cov==5.0.0
|
||||
pytest-django==4.9.0
|
||||
pytest-subtests==0.13.1
|
||||
|
|
@ -1,12 +1,5 @@
|
|||
-e .
|
||||
black==26.3.1
|
||||
coverage==7.13.4
|
||||
django-ipware>=3
|
||||
mypy==1.19.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.49.1
|
||||
-r requirements-qa.txt
|
||||
-r requirements-test.txt
|
||||
sphinx_rtd_theme==2.0.0
|
||||
tox==4.20.0
|
||||
|
|
|
|||
12
setup.py
12
setup.py
|
|
@ -35,9 +35,9 @@ setup(
|
|||
package_dir={"axes": "axes"},
|
||||
use_scm_version=True,
|
||||
setup_requires=["setuptools_scm"],
|
||||
python_requires=">=3.10",
|
||||
python_requires=">=3.7",
|
||||
install_requires=[
|
||||
"django>=4.2",
|
||||
"django>=3.2",
|
||||
"asgiref>=3.6.0",
|
||||
],
|
||||
extras_require={
|
||||
|
|
@ -51,19 +51,19 @@ setup(
|
|||
"Environment :: Plugins",
|
||||
"Framework :: Django",
|
||||
"Framework :: Django :: 4.2",
|
||||
"Framework :: Django :: 5.2",
|
||||
"Framework :: Django :: 6.0",
|
||||
"Framework :: Django :: 5.0",
|
||||
"Framework :: Django :: 5.1",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: System Administrators",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Topic :: Internet :: Log Analysis",
|
||||
"Topic :: Security",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ from string import ascii_letters, digits
|
|||
from time import sleep
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.http import HttpRequest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
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 axes.attempts import get_cool_off_threshold
|
||||
|
|
@ -15,13 +15,12 @@ class GetCoolOffThresholdTestCase(AxesTestCase):
|
|||
def test_get_cool_off_threshold(self):
|
||||
timestamp = now()
|
||||
|
||||
request = RequestFactory().post("/")
|
||||
with patch("axes.attempts.now", return_value=timestamp):
|
||||
request.axes_attempt_time = timestamp
|
||||
threshold_now = get_cool_off_threshold(request)
|
||||
attempt_time = timestamp
|
||||
threshold_now = get_cool_off_threshold(attempt_time)
|
||||
|
||||
request.axes_attempt_time = None
|
||||
threshold_none = get_cool_off_threshold(request)
|
||||
attempt_time = None
|
||||
threshold_none = get_cool_off_threshold(attempt_time)
|
||||
|
||||
self.assertEqual(threshold_now, threshold_none)
|
||||
|
||||
|
|
|
|||
|
|
@ -129,24 +129,3 @@ class ConfCheckTestCase(AxesTestCase):
|
|||
def test_valid_callable(self):
|
||||
warnings = run_checks()
|
||||
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])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -1,20 +1,18 @@
|
|||
from platform import python_implementation
|
||||
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 django.core.cache import cache
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import timedelta
|
||||
|
||||
from axes.conf import settings
|
||||
from axes.handlers.proxy import AxesProxyHandler
|
||||
from axes.helpers import get_client_str
|
||||
from axes.models import AccessAttempt, AccessLog, AccessFailureLog
|
||||
from tests.base import AxesTestCase
|
||||
|
||||
|
||||
|
|
@ -569,170 +567,3 @@ class AxesTestHandlerTestCase(AxesHandlerBaseTestCase):
|
|||
|
||||
def test_handler_get_failures(self):
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -59,38 +59,6 @@ class CacheTestCase(AxesTestCase):
|
|||
def test_get_cache_timeout_none(self):
|
||||
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):
|
||||
def test_iso8601(self):
|
||||
|
|
@ -947,7 +915,7 @@ class LockoutResponseTestCase(AxesTestCase):
|
|||
self.assertEqual(type(response), HttpResponse)
|
||||
|
||||
|
||||
def mock_get_cool_off_str(req):
|
||||
def mock_get_cool_off_str():
|
||||
return timedelta(seconds=30)
|
||||
|
||||
|
||||
|
|
@ -972,7 +940,7 @@ class AxesCoolOffTestCase(AxesTestCase):
|
|||
def test_get_cool_off_float_gt_0(self):
|
||||
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):
|
||||
self.assertEqual(get_cool_off(), timedelta(seconds=30))
|
||||
|
||||
|
|
@ -1013,16 +981,9 @@ def mock_get_lockout_response(request, credentials):
|
|||
return HttpResponse(status=400)
|
||||
|
||||
|
||||
def mock_get_lockout_response_with_original_response_param(
|
||||
request, response, credentials
|
||||
):
|
||||
return HttpResponse(status=400)
|
||||
|
||||
|
||||
class AxesLockoutTestCase(AxesTestCase):
|
||||
def setUp(self):
|
||||
self.request = HttpRequest()
|
||||
self.response = HttpResponse()
|
||||
self.credentials = dict()
|
||||
|
||||
def test_get_lockout_response(self):
|
||||
|
|
@ -1046,20 +1007,6 @@ class AxesLockoutTestCase(AxesTestCase):
|
|||
response = get_lockout_response(self.request, self.credentials)
|
||||
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)
|
||||
def test_get_lockout_response_override_invalid(self):
|
||||
with self.assertRaises(TypeError):
|
||||
|
|
|
|||
Loading…
Reference in a new issue