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 "8.3.0" have entirely different histories.
13 changed files with 43 additions and 96 deletions
|
|
@ -2,12 +2,6 @@
|
|||
Changes
|
||||
=======
|
||||
|
||||
8.3.1 (2026-02-11)
|
||||
------------------
|
||||
|
||||
- Fix configuration JSON serialization errors for Celery.
|
||||
[aleksihakli]
|
||||
|
||||
8.3.0 (2026-02-09)
|
||||
------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -19,10 +19,8 @@ class IsLockedOutFilter(admin.SimpleListFilter):
|
|||
|
||||
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__gte=settings.AXES_FAILURE_LIMIT)
|
||||
elif self.value() == "no":
|
||||
return queryset.filter(failures_since_start__lt=settings.AXES_FAILURE_LIMIT)
|
||||
return queryset
|
||||
|
||||
|
|
@ -36,9 +34,9 @@ class AccessAttemptAdmin(admin.ModelAdmin):
|
|||
"path_info",
|
||||
"failures_since_start",
|
||||
]
|
||||
|
||||
|
||||
if settings.AXES_USE_ATTEMPT_EXPIRATION:
|
||||
list_display.append("expiration")
|
||||
list_display.append('expiration')
|
||||
|
||||
list_filter = ["attempt_time", "path_info"]
|
||||
|
||||
|
|
@ -46,17 +44,14 @@ class AccessAttemptAdmin(admin.ModelAdmin):
|
|||
# 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]
|
||||
list_filter.append(IsLockedOutFilter)
|
||||
|
||||
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", "expiration")}),
|
||||
(_("Form Data"), {"fields": ("get_data", "post_data")}),
|
||||
(_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
|
||||
)
|
||||
|
|
@ -74,10 +69,10 @@ class AccessAttemptAdmin(admin.ModelAdmin):
|
|||
"expiration",
|
||||
]
|
||||
|
||||
actions = ["cleanup_expired_attempts"]
|
||||
actions = ['cleanup_expired_attempts']
|
||||
|
||||
@admin.action(description=_("Clean up expired attempts"))
|
||||
def cleanup_expired_attempts(self, request, queryset): # noqa
|
||||
@admin.action(description=_('Clean up expired attempts'))
|
||||
def cleanup_expired_attempts(self, request, queryset):
|
||||
count = self.handler.clean_expired_user_attempts(request=request)
|
||||
self.message_user(request, _(f"Cleaned up {count} expired access attempts."))
|
||||
|
||||
|
|
@ -90,15 +85,10 @@ class AccessAttemptAdmin(admin.ModelAdmin):
|
|||
|
||||
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")
|
||||
)
|
||||
|
||||
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 = (
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ def get_cool_off_threshold(request: Optional[HttpRequest] = None) -> datetime:
|
|||
"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]
|
||||
attempt_time = request.axes_attempt_time
|
||||
if attempt_time is None:
|
||||
return now() - cool_off
|
||||
return attempt_time - cool_off
|
||||
|
|
|
|||
|
|
@ -235,4 +235,4 @@ def is_valid_callable(value) -> bool:
|
|||
except ImportError:
|
||||
return False
|
||||
|
||||
return True
|
||||
return True
|
||||
20
axes/conf.py
20
axes/conf.py
|
|
@ -3,19 +3,6 @@ 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,7 +43,6 @@ 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
|
||||
|
|
@ -65,7 +51,7 @@ def _get_username_field_default():
|
|||
settings.AXES_USERNAME_FORM_FIELD = getattr(
|
||||
settings,
|
||||
"AXES_USERNAME_FORM_FIELD",
|
||||
JSONSerializableLazyObject(_get_username_field_default),
|
||||
SimpleLazyObject(_get_username_field_default),
|
||||
)
|
||||
|
||||
# use a specific password field to retrieve from login POST data
|
||||
|
|
@ -108,9 +94,7 @@ 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_USE_ATTEMPT_EXPIRATION = getattr(settings, "AXES_USE_ATTEMPT_EXPIRATION", False)
|
||||
|
||||
settings.AXES_VERBOSE = getattr(settings, "AXES_VERBOSE", settings.AXES_ENABLED)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,12 +21,7 @@ from axes.helpers import (
|
|||
get_query_str,
|
||||
get_attempt_expiration,
|
||||
)
|
||||
from axes.models import (
|
||||
AccessAttempt,
|
||||
AccessAttemptExpiration,
|
||||
AccessFailureLog,
|
||||
AccessLog,
|
||||
)
|
||||
from axes.models import AccessAttempt, AccessAttemptExpiration, AccessFailureLog, AccessLog
|
||||
from axes.signals import user_locked_out
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
|
@ -228,17 +223,15 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
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,
|
||||
"AXES: Creating new AccessAttemptExpiration for %s", client_str
|
||||
)
|
||||
attempt.expiration = AccessAttemptExpiration.objects.create(
|
||||
access_attempt=attempt,
|
||||
expires_at=get_attempt_expiration(request),
|
||||
expires_at=get_attempt_expiration(request)
|
||||
)
|
||||
else:
|
||||
attempt.expiration.expires_at = max(
|
||||
get_attempt_expiration(request),
|
||||
attempt.expiration.expires_at,
|
||||
get_attempt_expiration(request), attempt.expiration.expires_at
|
||||
)
|
||||
attempt.expiration.save()
|
||||
|
||||
|
|
@ -372,7 +365,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
return attempts_list
|
||||
|
||||
def get_user_attempts(
|
||||
self, request: HttpRequest, credentials: Optional[dict] = None # noqa
|
||||
self, request: HttpRequest, credentials: Optional[dict] = None
|
||||
) -> List[QuerySet]:
|
||||
"""
|
||||
Get list of querysets with valid user attempts that match the given request and credentials.
|
||||
|
|
@ -393,9 +386,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
]
|
||||
|
||||
def clean_expired_user_attempts(
|
||||
self,
|
||||
request: Optional[HttpRequest] = None,
|
||||
credentials: Optional[dict] = None, # noqa
|
||||
self, request: Optional[HttpRequest] = None, credentials: Optional[dict] = None
|
||||
) -> int:
|
||||
"""
|
||||
Clean expired user attempts from the database.
|
||||
|
|
@ -409,9 +400,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
|
||||
if settings.AXES_USE_ATTEMPT_EXPIRATION:
|
||||
threshold = timezone.now()
|
||||
count, _ = AccessAttempt.objects.filter(
|
||||
expiration__expires_at__lte=threshold
|
||||
).delete()
|
||||
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,
|
||||
|
|
@ -419,9 +408,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
)
|
||||
else:
|
||||
threshold = get_cool_off_threshold(request)
|
||||
count, _ = AccessAttempt.objects.filter(
|
||||
attempt_time__lte=threshold
|
||||
).delete()
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -100,7 +100,6 @@ def get_cool_off_iso8601(delta: timedelta) -> str:
|
|||
return f"P{days_str}T{time_str}"
|
||||
return f"P{days_str}"
|
||||
|
||||
|
||||
def get_attempt_expiration(request: Optional[HttpRequest] = None) -> datetime:
|
||||
"""
|
||||
Get threshold for fetching access attempts from the database.
|
||||
|
|
@ -112,12 +111,11 @@ def get_attempt_expiration(request: Optional[HttpRequest] = None) -> datetime:
|
|||
"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]
|
||||
attempt_time = request.axes_attempt_time
|
||||
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 +162,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"
|
||||
|
|
|
|||
|
|
@ -60,8 +60,6 @@ class AxesMiddleware:
|
|||
credentials = getattr(request, "axes_credentials", None)
|
||||
response = await sync_to_async(
|
||||
get_lockout_response, thread_sensitive=True
|
||||
)(
|
||||
request, credentials
|
||||
) # type: ignore
|
||||
)(request, credentials) # type: ignore
|
||||
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -68,12 +68,9 @@ class AccessAttemptExpiration(models.Model):
|
|||
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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
-------------------------------------
|
||||
|
|
|
|||
2
mypy.ini
2
mypy.ini
|
|
@ -1,5 +1,5 @@
|
|||
[mypy]
|
||||
python_version = 3.14
|
||||
python_version = 3.12
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-axes.migrations.*]
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ envlist =
|
|||
py{310,311,312}-dj42
|
||||
py{310,311,312,313}-dj52
|
||||
py{312,313,314}-dj60
|
||||
py314-djmain
|
||||
py314-djqa
|
||||
py312-djmain
|
||||
py312-djqa
|
||||
|
||||
[gh-actions]
|
||||
python =
|
||||
|
|
@ -36,9 +36,9 @@ DJANGO =
|
|||
[testenv]
|
||||
deps =
|
||||
-r requirements.txt
|
||||
dj42: django>=4.2,<4.3
|
||||
dj52: django>=5.2,<5.3
|
||||
dj60: django>=6.0,<6.1
|
||||
dj42: django>=4.2,<5
|
||||
dj52: django>=5.2,<6
|
||||
dj60: django>=6.0,<7
|
||||
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
|
||||
[testenv:py312-djqa]
|
||||
deps = -r requirements.txt
|
||||
commands =
|
||||
mypy axes
|
||||
prospector axes
|
||||
black --check --diff axes
|
||||
prospector
|
||||
black -t py312 --check --diff axes
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
-e .
|
||||
black==26.3.1
|
||||
coverage==7.13.4
|
||||
black==26.1.0
|
||||
coverage==7.13.3
|
||||
django-ipware>=3
|
||||
mypy==1.19.1
|
||||
prospector==1.18.0
|
||||
pytest-cov==7.0.0
|
||||
pytest-django==4.12.0
|
||||
pytest-django==4.11.1
|
||||
pytest-subtests==0.15.0
|
||||
pytest==9.0.2
|
||||
sphinx_rtd_theme==3.1.0
|
||||
tox==4.49.1
|
||||
tox==4.34.1
|
||||
|
|
|
|||
Loading…
Reference in a new issue