mirror of
https://github.com/jazzband/django-axes.git
synced 2026-05-05 14:14:46 +00:00
Compare commits
135 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b14b78a16e | ||
|
|
e4cdd72231 | ||
|
|
3fc256c8d2 | ||
|
|
1aa8509cdc | ||
|
|
46e206af49 | ||
|
|
0d7f4bdb43 | ||
|
|
cc0387ae60 | ||
|
|
fdd7b22cd3 | ||
|
|
a5d14cd630 | ||
|
|
2a31c0133f | ||
|
|
4624eed684 | ||
|
|
e27ce891ea | ||
|
|
c3dcd1ba51 | ||
|
|
41ebdc3063 | ||
|
|
31c69dbea5 | ||
|
|
bdd0c9546a | ||
|
|
4b77eb69ee | ||
|
|
5acae054b4 | ||
|
|
d59a289407 | ||
|
|
23ee2fca44 | ||
|
|
4ea615811b | ||
|
|
b4fb3088b4 | ||
|
|
6c8feada83 | ||
|
|
b441ccd5fc | ||
|
|
1d9964be16 | ||
|
|
60e3cceb1d | ||
|
|
8f5e9965d8 | ||
|
|
cf0be90f11 | ||
|
|
d033b70235 | ||
|
|
b14e861631 | ||
|
|
6703b66f17 | ||
|
|
95a8043341 | ||
|
|
f2af7c993b | ||
|
|
8869a9e594 | ||
|
|
0735c71432 | ||
|
|
332a5f57d0 | ||
|
|
a30b68aec9 | ||
|
|
29005e2f6f | ||
|
|
dd172ec1a5 | ||
|
|
0d5795cdf2 | ||
|
|
2fce8fafdf | ||
|
|
53dfc9a821 | ||
|
|
9ba27a0755 | ||
|
|
6866a53728 | ||
|
|
955f39da73 | ||
|
|
04fd39fa57 | ||
|
|
69c97d5c7b | ||
|
|
3f6e773f7d | ||
|
|
88827c381e | ||
|
|
e8c3bf7be7 | ||
|
|
9962313199 | ||
|
|
cf3d3eda2c | ||
|
|
2a02585d23 | ||
|
|
13f293b650 | ||
|
|
592452e446 | ||
|
|
2a8c42c3eb | ||
|
|
29fd4bd4fe | ||
|
|
af65488dc6 | ||
|
|
e4e0299252 | ||
|
|
75c29bd6f8 | ||
|
|
95f321e7c7 | ||
|
|
bebbbe924e | ||
|
|
34a350568e | ||
|
|
8340a7a82f | ||
|
|
392dfa0e44 | ||
|
|
baace5c27b | ||
|
|
ba7b72f9d9 | ||
|
|
01ccf5b213 | ||
|
|
d8e6c939fe | ||
|
|
94a66c7346 | ||
|
|
f5951e966c | ||
|
|
f583e93718 | ||
|
|
74c24c0e78 | ||
|
|
df8fb35e18 | ||
|
|
a1e9eff875 | ||
|
|
0fd9ccd1d4 | ||
|
|
864dfc2d9a | ||
|
|
d1fad02076 | ||
|
|
d79c7de4e5 | ||
|
|
c9f092a3be | ||
|
|
9becd0061e | ||
|
|
7e495fb5fd | ||
|
|
6cb8dc7a46 | ||
|
|
a340dec892 | ||
|
|
eea9939a45 | ||
|
|
31038278bd | ||
|
|
a58344c3ef | ||
|
|
9bc11398f4 | ||
|
|
dfa39d07c0 | ||
|
|
6d2c7b1431 | ||
|
|
bd3b56237d | ||
|
|
8356498a44 | ||
|
|
933756090a | ||
|
|
3ff5ada46d | ||
|
|
4115d59d14 | ||
|
|
5e7fbca52c | ||
|
|
599fbc0da0 | ||
|
|
b4792ff868 | ||
|
|
82a6ac63bb | ||
|
|
93d8285006 | ||
|
|
0115648a1d | ||
|
|
479a355d22 | ||
|
|
fdf22fffba | ||
|
|
682e4261c9 | ||
|
|
133f19b2f5 | ||
|
|
3e3da350ea | ||
|
|
ff9c3296ef | ||
|
|
6aedd78c0b | ||
|
|
be18f038f9 | ||
|
|
04638811d7 | ||
|
|
a0df8ae7c4 | ||
|
|
784f1930af | ||
|
|
8e600536b1 | ||
|
|
d590dd6fb9 | ||
|
|
0dab4d36cf | ||
|
|
09145a8fc7 | ||
|
|
4e21791ed6 | ||
|
|
c354217ee4 | ||
|
|
6ea4879c55 | ||
|
|
78de78261d | ||
|
|
8e0c2ec4b7 | ||
|
|
a0fd10da4c | ||
|
|
2fb772efdb | ||
|
|
129e93cc0e | ||
|
|
ce3bfd51be | ||
|
|
9a7673a47e | ||
|
|
4c3a36cf9a | ||
|
|
d17e4ecd4b | ||
|
|
4511695e9f | ||
|
|
43965514cb | ||
|
|
01c32f051f | ||
|
|
71bcfba42d | ||
|
|
67b94d0dfb | ||
|
|
9acda1f892 | ||
|
|
77ae2a2d14 |
33 changed files with 919 additions and 195 deletions
8
.github/workflows/codeql.yml
vendored
8
.github/workflows/codeql.yml
vendored
|
|
@ -14,11 +14,11 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3
|
uses: github/codeql-action/init@v4
|
||||||
# Override language selection by uncommenting this and choosing your languages
|
# Override language selection by uncommenting this and choosing your languages
|
||||||
# with:
|
# with:
|
||||||
# languages: go, javascript, csharp, python, cpp, java
|
# languages: go, javascript, csharp, python, cpp, java
|
||||||
|
|
@ -26,7 +26,7 @@ jobs:
|
||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below).
|
# If this step fails, then you should remove it and run the build manually (see below).
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@v3
|
uses: github/codeql-action/autobuild@v4
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
|
|
@ -40,4 +40,4 @@ jobs:
|
||||||
# make release
|
# make release
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v3
|
uses: github/codeql-action/analyze@v4
|
||||||
|
|
|
||||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -14,14 +14,14 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: 3.8
|
python-version: 3.12
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
28
.github/workflows/test.yml
vendored
28
.github/workflows/test.yml
vendored
|
|
@ -12,31 +12,31 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
|
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
||||||
django-version: ['4.2', '5.0', '5.1']
|
django-version: ['4.2', '5.2', '6.0']
|
||||||
include:
|
include:
|
||||||
# Tox configuration for QA environment
|
# Tox configuration for QA environment
|
||||||
- python-version: '3.12'
|
- python-version: '3.14'
|
||||||
django-version: 'qa'
|
django-version: 'qa'
|
||||||
# Django main
|
# Django main
|
||||||
- python-version: '3.12'
|
- python-version: '3.14'
|
||||||
django-version: 'main'
|
django-version: 'main'
|
||||||
experimental: true
|
experimental: true
|
||||||
exclude:
|
exclude:
|
||||||
- python-version: '3.8'
|
- python-version: '3.13'
|
||||||
django-version: '5.0'
|
django-version: '4.2'
|
||||||
- python-version: '3.9'
|
- python-version: '3.9'
|
||||||
django-version: '5.0'
|
django-version: '5.2'
|
||||||
- python-version: '3.8'
|
- python-version: '3.10'
|
||||||
django-version: '5.1'
|
django-version: '6.0'
|
||||||
- python-version: '3.9'
|
- python-version: '3.11'
|
||||||
django-version: '5.1'
|
django-version: '6.0'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
|
@ -46,7 +46,7 @@ jobs:
|
||||||
echo "::set-output name=dir::$(pip cache dir)"
|
echo "::set-output name=dir::$(pip cache dir)"
|
||||||
|
|
||||||
- name: Cache
|
- name: Cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pip-cache.outputs.dir }}
|
path: ${{ steps.pip-cache.outputs.dir }}
|
||||||
key:
|
key:
|
||||||
|
|
|
||||||
75
CHANGES.rst
75
CHANGES.rst
|
|
@ -2,6 +2,81 @@
|
||||||
Changes
|
Changes
|
||||||
=======
|
=======
|
||||||
|
|
||||||
|
8.3.1 (2026-02-11)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
- Fix configuration JSON serialization errors for Celery.
|
||||||
|
[aleksihakli]
|
||||||
|
|
||||||
|
8.3.0 (2026-02-09)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
- Remove deprecated pkg_resources in favour of new importlib.
|
||||||
|
[hugovk]
|
||||||
|
|
||||||
|
8.2.0 (2026-02-06)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
- Fix AttributeError when optional settings are undefined.
|
||||||
|
[rodrigo.nogueira]
|
||||||
|
- Fix circular import with custom user models.
|
||||||
|
[rodrigo.nogueira]
|
||||||
|
- Add unit tests for security check W006.
|
||||||
|
[shayanTaki]
|
||||||
|
|
||||||
|
8.1.0 (2025-12-19)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
- Add Persion (fa) translations for django-axes.
|
||||||
|
[AmirAli-BahramJerdi]
|
||||||
|
- Add individual attempt expiry support.
|
||||||
|
[kuldeepkhatke]
|
||||||
|
- Add checks for missing ip_address in lockout params.
|
||||||
|
[shayanTaki]
|
||||||
|
- Add missing ``settings.AXES_IPWARE_PROXY_ORDER`` documentation.
|
||||||
|
[ram98kgp]
|
||||||
|
- Enhance ``get_lockout_response`` to receive original response as parameter.
|
||||||
|
[mounirmesselmeni]
|
||||||
|
- Update documentation.
|
||||||
|
- Add Python 3.14 support.
|
||||||
|
- Add Django 6.0 support.
|
||||||
|
- Remove Python 3.9 support (EOL).
|
||||||
|
- Remove Django 5.1 support (EOL).
|
||||||
|
|
||||||
|
8.0.0 (2025-05-10)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
- Move all database related logic to the default ``axes.handlers.database.AxesDatabaseHandler``.
|
||||||
|
[nefrob]
|
||||||
|
|
||||||
|
7.1.0 (2025-04-23)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
- Provide credentials to expired credentials cleanup method.
|
||||||
|
[parul-aro]
|
||||||
|
- Update support matrix for Django 5.2.
|
||||||
|
[mkniewallner]
|
||||||
|
- Fix documentation.
|
||||||
|
[chango-goat]
|
||||||
|
|
||||||
|
|
||||||
|
7.0.2 (2025-02-19)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
- Fix documentation.
|
||||||
|
[Jacobus-afk]
|
||||||
|
- Default to using ``settings.AUTH_USER_MODEL.USERNAME_FIELD`` for resolving ``settings.AXES_USERNAME_FORM_FIELD`` if otherwise unset (previously "username").
|
||||||
|
[amneher]
|
||||||
|
|
||||||
|
|
||||||
|
7.0.1 (2024-12-02)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
- Add Python 3.13 support.
|
||||||
|
[aleksihakli]
|
||||||
|
- Deprecate Python 3.8 support.
|
||||||
|
[aleksihakli]
|
||||||
|
|
||||||
|
|
||||||
7.0.0 (2024-10-02)
|
7.0.0 (2024-10-02)
|
||||||
------------------
|
------------------
|
||||||
|
|
|
||||||
|
|
@ -4,26 +4,59 @@ from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from axes.conf import settings
|
from axes.conf import settings
|
||||||
from axes.models import AccessAttempt, AccessLog, AccessFailureLog
|
from axes.models import AccessAttempt, AccessLog, AccessFailureLog
|
||||||
|
from axes.handlers.database import AxesDatabaseHandler
|
||||||
|
|
||||||
|
|
||||||
|
class IsLockedOutFilter(admin.SimpleListFilter):
|
||||||
|
title = _("Locked Out")
|
||||||
|
parameter_name = "locked_out"
|
||||||
|
|
||||||
|
def lookups(self, request, model_admin):
|
||||||
|
return (
|
||||||
|
("yes", _("Yes")),
|
||||||
|
("no", _("No")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def queryset(self, request, queryset):
|
||||||
|
if self.value() == "yes":
|
||||||
|
return queryset.filter(
|
||||||
|
failures_since_start__gte=settings.AXES_FAILURE_LIMIT
|
||||||
|
)
|
||||||
|
if self.value() == "no":
|
||||||
|
return queryset.filter(failures_since_start__lt=settings.AXES_FAILURE_LIMIT)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
class AccessAttemptAdmin(admin.ModelAdmin):
|
class AccessAttemptAdmin(admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = [
|
||||||
"attempt_time",
|
"attempt_time",
|
||||||
"ip_address",
|
"ip_address",
|
||||||
"user_agent",
|
"user_agent",
|
||||||
"username",
|
"username",
|
||||||
"path_info",
|
"path_info",
|
||||||
"failures_since_start",
|
"failures_since_start",
|
||||||
)
|
]
|
||||||
|
|
||||||
|
if settings.AXES_USE_ATTEMPT_EXPIRATION:
|
||||||
|
list_display.append("expiration")
|
||||||
|
|
||||||
list_filter = ["attempt_time", "path_info"]
|
list_filter = ["attempt_time", "path_info"]
|
||||||
|
|
||||||
|
if isinstance(settings.AXES_FAILURE_LIMIT, int) and settings.AXES_FAILURE_LIMIT > 0:
|
||||||
|
# This will only add the status field if AXES_FAILURE_LIMIT is set to a positive integer
|
||||||
|
# Because callable failure limit requires scope of request object
|
||||||
|
list_display.append("status")
|
||||||
|
list_filter.append(IsLockedOutFilter) # type: ignore[arg-type]
|
||||||
|
|
||||||
search_fields = ["ip_address", "username", "user_agent", "path_info"]
|
search_fields = ["ip_address", "username", "user_agent", "path_info"]
|
||||||
|
|
||||||
date_hierarchy = "attempt_time"
|
date_hierarchy = "attempt_time"
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("username", "path_info", "failures_since_start")}),
|
(
|
||||||
|
None,
|
||||||
|
{"fields": ("username", "path_info", "failures_since_start", "expiration")},
|
||||||
|
),
|
||||||
(_("Form Data"), {"fields": ("get_data", "post_data")}),
|
(_("Form Data"), {"fields": ("get_data", "post_data")}),
|
||||||
(_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
|
(_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
|
||||||
)
|
)
|
||||||
|
|
@ -38,11 +71,34 @@ class AccessAttemptAdmin(admin.ModelAdmin):
|
||||||
"get_data",
|
"get_data",
|
||||||
"post_data",
|
"post_data",
|
||||||
"failures_since_start",
|
"failures_since_start",
|
||||||
|
"expiration",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
actions = ["cleanup_expired_attempts"]
|
||||||
|
|
||||||
|
@admin.action(description=_("Clean up expired attempts"))
|
||||||
|
def cleanup_expired_attempts(self, request, queryset): # noqa
|
||||||
|
count = self.handler.clean_expired_user_attempts(request=request)
|
||||||
|
self.message_user(request, _(f"Cleaned up {count} expired access attempts."))
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.handler = AxesDatabaseHandler()
|
||||||
|
|
||||||
def has_add_permission(self, request: HttpRequest) -> bool:
|
def has_add_permission(self, request: HttpRequest) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def expiration(self, obj: AccessAttempt):
|
||||||
|
return obj.expiration.expires_at if hasattr(obj, "expiration") else _("Not set")
|
||||||
|
|
||||||
|
def status(self, obj: AccessAttempt):
|
||||||
|
return (
|
||||||
|
f"{settings.AXES_FAILURE_LIMIT - obj.failures_since_start} "
|
||||||
|
+ _("Attempt Remaining")
|
||||||
|
if obj.failures_since_start < settings.AXES_FAILURE_LIMIT
|
||||||
|
else _("Locked Out")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AccessLogAdmin(admin.ModelAdmin):
|
class AccessLogAdmin(admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import List, Optional
|
from typing import Optional
|
||||||
|
|
||||||
from django.db.models import QuerySet
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.utils.timezone import datetime, now
|
from django.utils.timezone import datetime, now
|
||||||
|
|
||||||
from axes.conf import settings
|
from axes.helpers import get_cool_off
|
||||||
from axes.helpers import get_client_username, get_client_parameters, get_cool_off
|
|
||||||
from axes.models import AccessAttempt
|
|
||||||
|
|
||||||
log = getLogger(__name__)
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -23,85 +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"
|
"Cool off threshold can not be calculated with settings.AXES_COOLOFF_TIME set to None"
|
||||||
)
|
)
|
||||||
|
|
||||||
attempt_time = request.axes_attempt_time
|
attempt_time = request.axes_attempt_time # type: ignore[union-attr]
|
||||||
if attempt_time is None:
|
if attempt_time is None:
|
||||||
return now() - cool_off
|
return now() - cool_off
|
||||||
return attempt_time - cool_off
|
return attempt_time - cool_off
|
||||||
|
|
||||||
|
|
||||||
def filter_user_attempts(
|
|
||||||
request: HttpRequest, credentials: Optional[dict] = None
|
|
||||||
) -> List[QuerySet]:
|
|
||||||
"""
|
|
||||||
Return a list querysets of AccessAttempts that match the given request and credentials.
|
|
||||||
"""
|
|
||||||
|
|
||||||
username = get_client_username(request, credentials)
|
|
||||||
|
|
||||||
filter_kwargs_list = get_client_parameters(
|
|
||||||
username, request.axes_ip_address, request.axes_user_agent, request, credentials
|
|
||||||
)
|
|
||||||
attempts_list = [
|
|
||||||
AccessAttempt.objects.filter(**filter_kwargs)
|
|
||||||
for filter_kwargs in filter_kwargs_list
|
|
||||||
]
|
|
||||||
return attempts_list
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_attempts(
|
|
||||||
request: HttpRequest, credentials: Optional[dict] = None
|
|
||||||
) -> List[QuerySet]:
|
|
||||||
"""
|
|
||||||
Get list of querysets with valid user attempts that match the given request and credentials.
|
|
||||||
"""
|
|
||||||
|
|
||||||
attempts_list = filter_user_attempts(request, credentials)
|
|
||||||
|
|
||||||
if settings.AXES_COOLOFF_TIME is None:
|
|
||||||
log.debug(
|
|
||||||
"AXES: Getting all access attempts from database because no AXES_COOLOFF_TIME is configured"
|
|
||||||
)
|
|
||||||
return attempts_list
|
|
||||||
|
|
||||||
threshold = get_cool_off_threshold(request)
|
|
||||||
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(request: Optional[HttpRequest] = 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(request)
|
|
||||||
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,6 +22,10 @@ class Messages:
|
||||||
BACKEND_INVALID = "You do not have 'axes.backends.AxesStandaloneBackend' or a subclass in your settings.AUTHENTICATION_BACKENDS."
|
BACKEND_INVALID = "You do not have 'axes.backends.AxesStandaloneBackend' or a subclass in your settings.AUTHENTICATION_BACKENDS."
|
||||||
SETTING_DEPRECATED = "You have a deprecated setting {deprecated_setting} configured in your project settings"
|
SETTING_DEPRECATED = "You have a deprecated setting {deprecated_setting} configured in your project settings"
|
||||||
CALLABLE_INVALID = "{callable_setting} is not a valid callable."
|
CALLABLE_INVALID = "{callable_setting} is not a valid callable."
|
||||||
|
LOCKOUT_PARAMETERS_INVALID = (
|
||||||
|
"AXES_LOCKOUT_PARAMETERS does not contain 'ip_address'."
|
||||||
|
" This configuration allows attackers to bypass rate limits by rotating User-Agents or Cookies."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Hints:
|
class Hints:
|
||||||
|
|
@ -30,6 +34,7 @@ class Hints:
|
||||||
BACKEND_INVALID = "AxesModelBackend was renamed to AxesStandaloneBackend in django-axes version 5.0."
|
BACKEND_INVALID = "AxesModelBackend was renamed to AxesStandaloneBackend in django-axes version 5.0."
|
||||||
SETTING_DEPRECATED = None
|
SETTING_DEPRECATED = None
|
||||||
CALLABLE_INVALID = None
|
CALLABLE_INVALID = None
|
||||||
|
LOCKOUT_PARAMETERS_INVALID = "Add 'ip_address' to AXES_LOCKOUT_PARAMETERS."
|
||||||
|
|
||||||
|
|
||||||
class Codes:
|
class Codes:
|
||||||
|
|
@ -38,6 +43,7 @@ class Codes:
|
||||||
BACKEND_INVALID = "axes.W003"
|
BACKEND_INVALID = "axes.W003"
|
||||||
SETTING_DEPRECATED = "axes.W004"
|
SETTING_DEPRECATED = "axes.W004"
|
||||||
CALLABLE_INVALID = "axes.W005"
|
CALLABLE_INVALID = "axes.W005"
|
||||||
|
LOCKOUT_PARAMETERS_INVALID = "axes.W006"
|
||||||
|
|
||||||
|
|
||||||
@register(Tags.security, Tags.caches, Tags.compatibility)
|
@register(Tags.security, Tags.caches, Tags.compatibility)
|
||||||
|
|
@ -158,6 +164,34 @@ def axes_deprecation_check(app_configs, **kwargs): # pylint: disable=unused-arg
|
||||||
return warnings
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
@register(Tags.security)
|
||||||
|
def axes_lockout_params_check(app_configs, **kwargs): # pylint: disable=unused-argument
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
lockout_params = getattr(settings, "AXES_LOCKOUT_PARAMETERS", None)
|
||||||
|
|
||||||
|
if isinstance(lockout_params, (list, tuple)):
|
||||||
|
has_ip = False
|
||||||
|
for param in lockout_params:
|
||||||
|
if param == "ip_address":
|
||||||
|
has_ip = True
|
||||||
|
break
|
||||||
|
if isinstance(param, (list, tuple)) and "ip_address" in param:
|
||||||
|
has_ip = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not has_ip:
|
||||||
|
warnings.append(
|
||||||
|
Warning(
|
||||||
|
msg=Messages.LOCKOUT_PARAMETERS_INVALID,
|
||||||
|
hint=Hints.LOCKOUT_PARAMETERS_INVALID,
|
||||||
|
id=Codes.LOCKOUT_PARAMETERS_INVALID,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
@register
|
@register
|
||||||
def axes_conf_check(app_configs, **kwargs): # pylint: disable=unused-argument
|
def axes_conf_check(app_configs, **kwargs): # pylint: disable=unused-argument
|
||||||
warnings = []
|
warnings = []
|
||||||
|
|
@ -173,7 +207,7 @@ def axes_conf_check(app_configs, **kwargs): # pylint: disable=unused-argument
|
||||||
]
|
]
|
||||||
|
|
||||||
for callable_setting in callable_settings:
|
for callable_setting in callable_settings:
|
||||||
value = getattr(settings, callable_setting)
|
value = getattr(settings, callable_setting, None)
|
||||||
if not is_valid_callable(value):
|
if not is_valid_callable(value):
|
||||||
warnings.append(
|
warnings.append(
|
||||||
Warning(
|
Warning(
|
||||||
|
|
|
||||||
28
axes/conf.py
28
axes/conf.py
|
|
@ -1,6 +1,21 @@
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils.functional import SimpleLazyObject
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class JSONSerializableLazyObject(SimpleLazyObject):
|
||||||
|
"""
|
||||||
|
Celery/Kombu config inspection may JSON-encode Django settings.
|
||||||
|
Provide a JSON-friendly representation for lazy values.
|
||||||
|
|
||||||
|
Fixes jazzband/django-axes#1391
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __json__(self):
|
||||||
|
return str(self)
|
||||||
|
|
||||||
|
|
||||||
# disable plugin when set to False
|
# disable plugin when set to False
|
||||||
settings.AXES_ENABLED = getattr(settings, "AXES_ENABLED", True)
|
settings.AXES_ENABLED = getattr(settings, "AXES_ENABLED", True)
|
||||||
|
|
||||||
|
|
@ -41,9 +56,16 @@ settings.AXES_ONLY_ADMIN_SITE = getattr(settings, "AXES_ONLY_ADMIN_SITE", False)
|
||||||
# show Axes logs in admin
|
# show Axes logs in admin
|
||||||
settings.AXES_ENABLE_ADMIN = getattr(settings, "AXES_ENABLE_ADMIN", True)
|
settings.AXES_ENABLE_ADMIN = getattr(settings, "AXES_ENABLE_ADMIN", True)
|
||||||
|
|
||||||
|
|
||||||
# use a specific username field to retrieve from login POST data
|
# use a specific username field to retrieve from login POST data
|
||||||
|
def _get_username_field_default():
|
||||||
|
return get_user_model().USERNAME_FIELD
|
||||||
|
|
||||||
|
|
||||||
settings.AXES_USERNAME_FORM_FIELD = getattr(
|
settings.AXES_USERNAME_FORM_FIELD = getattr(
|
||||||
settings, "AXES_USERNAME_FORM_FIELD", "username"
|
settings,
|
||||||
|
"AXES_USERNAME_FORM_FIELD",
|
||||||
|
JSONSerializableLazyObject(_get_username_field_default),
|
||||||
)
|
)
|
||||||
|
|
||||||
# use a specific password field to retrieve from login POST data
|
# use a specific password field to retrieve from login POST data
|
||||||
|
|
@ -86,6 +108,10 @@ settings.AXES_LOCKOUT_URL = getattr(settings, "AXES_LOCKOUT_URL", None)
|
||||||
|
|
||||||
settings.AXES_COOLOFF_TIME = getattr(settings, "AXES_COOLOFF_TIME", None)
|
settings.AXES_COOLOFF_TIME = getattr(settings, "AXES_COOLOFF_TIME", None)
|
||||||
|
|
||||||
|
settings.AXES_USE_ATTEMPT_EXPIRATION = getattr(
|
||||||
|
settings, "AXES_USE_ATTEMPT_EXPIRATION", False
|
||||||
|
)
|
||||||
|
|
||||||
settings.AXES_VERBOSE = getattr(settings, "AXES_VERBOSE", settings.AXES_ENABLED)
|
settings.AXES_VERBOSE = getattr(settings, "AXES_VERBOSE", settings.AXES_ENABLED)
|
||||||
|
|
||||||
# whitelist and blacklist
|
# whitelist and blacklist
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,17 @@
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from django.db import router, transaction
|
from django.db import router, transaction
|
||||||
from django.db.models import F, Q, Sum, Value
|
from django.db.models import F, Q, QuerySet, Sum, Value
|
||||||
from django.db.models.functions import Concat
|
from django.db.models.functions import Concat
|
||||||
|
from django.http import HttpRequest
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from axes.attempts import (
|
from axes.attempts import get_cool_off_threshold
|
||||||
clean_expired_user_attempts,
|
|
||||||
get_user_attempts,
|
|
||||||
reset_user_attempts,
|
|
||||||
)
|
|
||||||
from axes.conf import settings
|
from axes.conf import settings
|
||||||
from axes.handlers.base import AxesBaseHandler, AbstractAxesHandler
|
from axes.handlers.base import AbstractAxesHandler, AxesBaseHandler
|
||||||
from axes.helpers import (
|
from axes.helpers import (
|
||||||
|
get_client_parameters,
|
||||||
get_client_session_hash,
|
get_client_session_hash,
|
||||||
get_client_str,
|
get_client_str,
|
||||||
get_client_username,
|
get_client_username,
|
||||||
|
|
@ -21,8 +19,14 @@ from axes.helpers import (
|
||||||
get_failure_limit,
|
get_failure_limit,
|
||||||
get_lockout_parameters,
|
get_lockout_parameters,
|
||||||
get_query_str,
|
get_query_str,
|
||||||
|
get_attempt_expiration,
|
||||||
|
)
|
||||||
|
from axes.models import (
|
||||||
|
AccessAttempt,
|
||||||
|
AccessAttemptExpiration,
|
||||||
|
AccessFailureLog,
|
||||||
|
AccessLog,
|
||||||
)
|
)
|
||||||
from axes.models import AccessLog, AccessAttempt, AccessFailureLog
|
|
||||||
from axes.signals import user_locked_out
|
from axes.signals import user_locked_out
|
||||||
|
|
||||||
log = getLogger(__name__)
|
log = getLogger(__name__)
|
||||||
|
|
@ -104,7 +108,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
return count
|
return count
|
||||||
|
|
||||||
def get_failures(self, request, credentials: Optional[dict] = None) -> int:
|
def get_failures(self, request, credentials: Optional[dict] = None) -> int:
|
||||||
attempts_list = get_user_attempts(request, credentials)
|
attempts_list = self.get_user_attempts(request, credentials)
|
||||||
attempt_count = max(
|
attempt_count = max(
|
||||||
(
|
(
|
||||||
attempts.aggregate(Sum("failures_since_start"))[
|
attempts.aggregate(Sum("failures_since_start"))[
|
||||||
|
|
@ -117,10 +121,10 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
return attempt_count
|
return attempt_count
|
||||||
|
|
||||||
def user_login_failed(self, sender, credentials: dict, request=None, **kwargs):
|
def user_login_failed(self, sender, credentials: dict, request=None, **kwargs):
|
||||||
"""When user login fails, save AccessFailureLog record in database,
|
"""
|
||||||
|
When user login fails, save AccessFailureLog record in database,
|
||||||
save AccessAttempt record in database, mark request with
|
save AccessAttempt record in database, mark request with
|
||||||
lockout attribute and emit lockout signal.
|
lockout attribute and emit lockout signal.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
log.info("AXES: User login failed, running database handler for failure.")
|
log.info("AXES: User login failed, running database handler for failure.")
|
||||||
|
|
@ -132,7 +136,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
return
|
return
|
||||||
|
|
||||||
# 1. database query: Clean up expired user attempts from the database before logging new attempts
|
# 1. database query: Clean up expired user attempts from the database before logging new attempts
|
||||||
clean_expired_user_attempts(request)
|
self.clean_expired_user_attempts(request, credentials)
|
||||||
|
|
||||||
username = get_client_username(request, credentials)
|
username = get_client_username(request, credentials)
|
||||||
client_str = get_client_str(
|
client_str = get_client_str(
|
||||||
|
|
@ -221,6 +225,23 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
client_str,
|
client_str,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if settings.AXES_USE_ATTEMPT_EXPIRATION:
|
||||||
|
if not hasattr(attempt, "expiration") or attempt.expiration is None:
|
||||||
|
log.debug(
|
||||||
|
"AXES: Creating new AccessAttemptExpiration for %s",
|
||||||
|
client_str,
|
||||||
|
)
|
||||||
|
attempt.expiration = AccessAttemptExpiration.objects.create(
|
||||||
|
access_attempt=attempt,
|
||||||
|
expires_at=get_attempt_expiration(request),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
attempt.expiration.expires_at = max(
|
||||||
|
get_attempt_expiration(request),
|
||||||
|
attempt.expiration.expires_at,
|
||||||
|
)
|
||||||
|
attempt.expiration.save()
|
||||||
|
|
||||||
# 3. or 4. database query: Calculate the current maximum failure number from the existing attempts
|
# 3. or 4. database query: Calculate the current maximum failure number from the existing attempts
|
||||||
failures_since_start = self.get_failures(request, credentials)
|
failures_since_start = self.get_failures(request, credentials)
|
||||||
request.axes_failures_since_start = failures_since_start
|
request.axes_failures_since_start = failures_since_start
|
||||||
|
|
@ -261,9 +282,6 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
When user logs in, update the AccessLog related to the user.
|
When user logs in, update the AccessLog related to the user.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 1. database query: Clean up expired user attempts from the database
|
|
||||||
clean_expired_user_attempts(request)
|
|
||||||
|
|
||||||
username = user.get_username()
|
username = user.get_username()
|
||||||
credentials = get_credentials(username)
|
credentials = get_credentials(username)
|
||||||
client_str = get_client_str(
|
client_str = get_client_str(
|
||||||
|
|
@ -276,6 +294,9 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
|
|
||||||
log.info("AXES: Successful login by %s.", client_str)
|
log.info("AXES: Successful login by %s.", client_str)
|
||||||
|
|
||||||
|
# 1. database query: Clean up expired user attempts from the database
|
||||||
|
self.clean_expired_user_attempts(request, credentials)
|
||||||
|
|
||||||
if not settings.AXES_DISABLE_ACCESS_LOG:
|
if not settings.AXES_DISABLE_ACCESS_LOG:
|
||||||
# 2. database query: Insert new access logs with login time
|
# 2. database query: Insert new access logs with login time
|
||||||
AccessLog.objects.create(
|
AccessLog.objects.create(
|
||||||
|
|
@ -292,7 +313,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
|
|
||||||
if settings.AXES_RESET_ON_SUCCESS:
|
if settings.AXES_RESET_ON_SUCCESS:
|
||||||
# 3. database query: Reset failed attempts for the logging in user
|
# 3. database query: Reset failed attempts for the logging in user
|
||||||
count = reset_user_attempts(request, credentials)
|
count = self.reset_user_attempts(request, credentials)
|
||||||
log.info(
|
log.info(
|
||||||
"AXES: Deleted %d failed login attempts by %s from database.",
|
"AXES: Deleted %d failed login attempts by %s from database.",
|
||||||
count,
|
count,
|
||||||
|
|
@ -304,10 +325,8 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
When user logs out, update the AccessLog related to the user.
|
When user logs out, update the AccessLog related to the user.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 1. database query: Clean up expired user attempts from the database
|
|
||||||
clean_expired_user_attempts(request)
|
|
||||||
|
|
||||||
username = user.get_username() if user else None
|
username = user.get_username() if user else None
|
||||||
|
credentials = get_credentials(username) if username else None
|
||||||
client_str = get_client_str(
|
client_str = get_client_str(
|
||||||
username,
|
username,
|
||||||
request.axes_ip_address,
|
request.axes_ip_address,
|
||||||
|
|
@ -316,6 +335,9 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
request,
|
request,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 1. database query: Clean up expired user attempts from the database
|
||||||
|
self.clean_expired_user_attempts(request, credentials)
|
||||||
|
|
||||||
log.info("AXES: Successful logout by %s.", client_str)
|
log.info("AXES: Successful logout by %s.", client_str)
|
||||||
|
|
||||||
if username and not settings.AXES_DISABLE_ACCESS_LOG:
|
if username and not settings.AXES_DISABLE_ACCESS_LOG:
|
||||||
|
|
@ -327,6 +349,103 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
session_hash=get_client_session_hash(request),
|
session_hash=get_client_session_hash(request),
|
||||||
).update(logout_time=request.axes_attempt_time)
|
).update(logout_time=request.axes_attempt_time)
|
||||||
|
|
||||||
|
def filter_user_attempts(
|
||||||
|
self, request: HttpRequest, credentials: Optional[dict] = None
|
||||||
|
) -> List[QuerySet]:
|
||||||
|
"""
|
||||||
|
Return a list querysets of AccessAttempts that match the given request and credentials.
|
||||||
|
"""
|
||||||
|
|
||||||
|
username = get_client_username(request, credentials)
|
||||||
|
|
||||||
|
filter_kwargs_list = get_client_parameters(
|
||||||
|
username,
|
||||||
|
request.axes_ip_address,
|
||||||
|
request.axes_user_agent,
|
||||||
|
request,
|
||||||
|
credentials,
|
||||||
|
)
|
||||||
|
attempts_list = [
|
||||||
|
AccessAttempt.objects.filter(**filter_kwargs)
|
||||||
|
for filter_kwargs in filter_kwargs_list
|
||||||
|
]
|
||||||
|
return attempts_list
|
||||||
|
|
||||||
|
def get_user_attempts(
|
||||||
|
self, request: HttpRequest, credentials: Optional[dict] = None # noqa
|
||||||
|
) -> List[QuerySet]:
|
||||||
|
"""
|
||||||
|
Get list of querysets with valid user attempts that match the given request and credentials.
|
||||||
|
"""
|
||||||
|
|
||||||
|
attempts_list = self.filter_user_attempts(request, credentials)
|
||||||
|
|
||||||
|
if settings.AXES_COOLOFF_TIME is None:
|
||||||
|
log.debug(
|
||||||
|
"AXES: Getting all access attempts from database because no AXES_COOLOFF_TIME is configured"
|
||||||
|
)
|
||||||
|
return attempts_list
|
||||||
|
|
||||||
|
threshold = get_cool_off_threshold(request)
|
||||||
|
log.debug("AXES: Getting access attempts that are newer than %s", threshold)
|
||||||
|
return [
|
||||||
|
attempts.filter(attempt_time__gte=threshold) for attempts in attempts_list
|
||||||
|
]
|
||||||
|
|
||||||
|
def clean_expired_user_attempts(
|
||||||
|
self,
|
||||||
|
request: Optional[HttpRequest] = None,
|
||||||
|
credentials: Optional[dict] = None, # noqa
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Clean expired user attempts from the database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if settings.AXES_COOLOFF_TIME is None:
|
||||||
|
log.debug(
|
||||||
|
"AXES: Skipping clean for expired access attempts because no AXES_COOLOFF_TIME is configured"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if settings.AXES_USE_ATTEMPT_EXPIRATION:
|
||||||
|
threshold = timezone.now()
|
||||||
|
count, _ = AccessAttempt.objects.filter(
|
||||||
|
expiration__expires_at__lte=threshold
|
||||||
|
).delete()
|
||||||
|
log.info(
|
||||||
|
"AXES: Cleaned up %s expired access attempts from database that expiry were older than %s",
|
||||||
|
count,
|
||||||
|
threshold,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
threshold = get_cool_off_threshold(request)
|
||||||
|
count, _ = AccessAttempt.objects.filter(
|
||||||
|
attempt_time__lte=threshold
|
||||||
|
).delete()
|
||||||
|
log.info(
|
||||||
|
"AXES: Cleaned up %s expired access attempts from database that were older than %s",
|
||||||
|
count,
|
||||||
|
threshold,
|
||||||
|
)
|
||||||
|
return count
|
||||||
|
|
||||||
|
def reset_user_attempts(
|
||||||
|
self, request: HttpRequest, credentials: Optional[dict] = None
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Reset all user attempts that match the given request and credentials.
|
||||||
|
"""
|
||||||
|
|
||||||
|
attempts_list = self.filter_user_attempts(request, credentials)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for attempts in attempts_list:
|
||||||
|
_count, _ = attempts.delete()
|
||||||
|
count += _count
|
||||||
|
log.info("AXES: Reset %s access attempts from database.", count)
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
def post_save_access_attempt(self, instance, **kwargs):
|
def post_save_access_attempt(self, instance, **kwargs):
|
||||||
"""
|
"""
|
||||||
Handles the ``axes.models.AccessAttempt`` object post save signal.
|
Handles the ``axes.models.AccessAttempt`` object post save signal.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from datetime import timedelta
|
from datetime import timedelta, datetime
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from string import Template
|
from string import Template
|
||||||
|
|
@ -101,6 +101,23 @@ def get_cool_off_iso8601(delta: timedelta) -> str:
|
||||||
return f"P{days_str}"
|
return f"P{days_str}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_attempt_expiration(request: Optional[HttpRequest] = None) -> datetime:
|
||||||
|
"""
|
||||||
|
Get threshold for fetching access attempts from the database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
cool_off = get_cool_off(request)
|
||||||
|
if cool_off is None:
|
||||||
|
raise TypeError(
|
||||||
|
"Cool off threshold can not be calculated with settings.AXES_COOLOFF_TIME set to None"
|
||||||
|
)
|
||||||
|
|
||||||
|
attempt_time = request.axes_attempt_time # type: ignore[union-attr]
|
||||||
|
if attempt_time is None:
|
||||||
|
return datetime.now() + cool_off
|
||||||
|
return attempt_time + cool_off
|
||||||
|
|
||||||
|
|
||||||
def get_credentials(username: Optional[str] = None, **kwargs) -> dict:
|
def get_credentials(username: Optional[str] = None, **kwargs) -> dict:
|
||||||
"""
|
"""
|
||||||
Calculate credentials for Axes to use internally from given username and kwargs.
|
Calculate credentials for Axes to use internally from given username and kwargs.
|
||||||
|
|
@ -147,7 +164,7 @@ def get_client_username(
|
||||||
log.debug(
|
log.debug(
|
||||||
"Using parameter credentials to get username with key settings.AXES_USERNAME_FORM_FIELD"
|
"Using parameter credentials to get username with key settings.AXES_USERNAME_FORM_FIELD"
|
||||||
)
|
)
|
||||||
return credentials.get(settings.AXES_USERNAME_FORM_FIELD, None)
|
return credentials.get(settings.AXES_USERNAME_FORM_FIELD, None) # type: ignore[return-value]
|
||||||
|
|
||||||
log.debug(
|
log.debug(
|
||||||
"Using parameter request.POST to get username with key settings.AXES_USERNAME_FORM_FIELD"
|
"Using parameter request.POST to get username with key settings.AXES_USERNAME_FORM_FIELD"
|
||||||
|
|
@ -445,15 +462,27 @@ def get_lockout_message() -> str:
|
||||||
|
|
||||||
|
|
||||||
def get_lockout_response(
|
def get_lockout_response(
|
||||||
request: HttpRequest, credentials: Optional[dict] = None
|
request: HttpRequest,
|
||||||
|
original_response: Optional[HttpResponse] = None,
|
||||||
|
credentials: Optional[dict] = None,
|
||||||
) -> HttpResponse:
|
) -> HttpResponse:
|
||||||
if settings.AXES_LOCKOUT_CALLABLE:
|
if settings.AXES_LOCKOUT_CALLABLE:
|
||||||
if callable(settings.AXES_LOCKOUT_CALLABLE):
|
if callable(settings.AXES_LOCKOUT_CALLABLE):
|
||||||
return settings.AXES_LOCKOUT_CALLABLE( # pylint: disable=not-callable
|
# Try calling with 3 args, fallback to 2 for backward compatibility
|
||||||
request, credentials
|
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)
|
||||||
if isinstance(settings.AXES_LOCKOUT_CALLABLE, str):
|
if isinstance(settings.AXES_LOCKOUT_CALLABLE, str):
|
||||||
return import_string(settings.AXES_LOCKOUT_CALLABLE)(request, credentials)
|
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)
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
"settings.AXES_LOCKOUT_CALLABLE needs to be a string, callable, or None."
|
"settings.AXES_LOCKOUT_CALLABLE needs to be a string, callable, or None."
|
||||||
)
|
)
|
||||||
|
|
|
||||||
BIN
axes/locale/fa/LC_MESSAGES/django.mo
Normal file
BIN
axes/locale/fa/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
109
axes/locale/fa/LC_MESSAGES/django.po
Normal file
109
axes/locale/fa/LC_MESSAGES/django.po
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
# ترجمه فارسی برای 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 settings.AXES_ENABLED:
|
||||||
if getattr(request, "axes_locked_out", None):
|
if getattr(request, "axes_locked_out", None):
|
||||||
credentials = getattr(request, "axes_credentials", None)
|
credentials = getattr(request, "axes_credentials", None)
|
||||||
response = get_lockout_response(request, credentials) # type: ignore
|
response = get_lockout_response(request, response, credentials) # type: ignore
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
|
||||||
41
axes/migrations/0010_accessattemptexpiration.py
Normal file
41
axes/migrations/0010_accessattemptexpiration.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# 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,9 +51,29 @@ class AccessAttempt(AccessBase):
|
||||||
unique_together = [["username", "ip_address", "user_agent"]]
|
unique_together = [["username", "ip_address", "user_agent"]]
|
||||||
|
|
||||||
|
|
||||||
|
class AccessAttemptExpiration(models.Model):
|
||||||
|
access_attempt = models.OneToOneField(
|
||||||
|
AccessAttempt,
|
||||||
|
primary_key=True,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="expiration",
|
||||||
|
verbose_name=_("Access Attempt"),
|
||||||
|
)
|
||||||
|
expires_at = models.DateTimeField(
|
||||||
|
_("Expires At"),
|
||||||
|
help_text=_("The time when access attempt expires and is no longer valid."),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("access attempt expiration")
|
||||||
|
verbose_name_plural = _("access attempt expirations")
|
||||||
|
|
||||||
|
|
||||||
class AccessLog(AccessBase):
|
class AccessLog(AccessBase):
|
||||||
logout_time = models.DateTimeField(_("Logout Time"), null=True, blank=True)
|
logout_time = models.DateTimeField(_("Logout Time"), null=True, blank=True)
|
||||||
session_hash = models.CharField(_("Session 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):
|
def __str__(self):
|
||||||
return f"Access Log for {self.username} @ {self.attempt_time}"
|
return f"Access Log for {self.username} @ {self.attempt_time}"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Requirements
|
Requirements
|
||||||
============
|
============
|
||||||
|
|
||||||
Axes requires a supported Django version and runs on Python versions 3.8 and above.
|
Axes requires a supported Django version and runs on Python versions 3.9 and above.
|
||||||
|
|
||||||
Refer to the project source code repository in
|
Refer to the project source code repository in
|
||||||
`GitHub <https://github.com/jazzband/django-axes/>`_ and see the
|
`GitHub <https://github.com/jazzband/django-axes/>`_ and see the
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,9 @@ if you have any custom logic to override Django's standard permissions checks.
|
||||||
# on failed user authentication attempts from login views.
|
# on failed user authentication attempts from login views.
|
||||||
# If you do not want Axes to override the authentication response
|
# If you do not want Axes to override the authentication response
|
||||||
# you can skip installing the middleware and use your own views.
|
# you can skip installing the middleware and use your own views.
|
||||||
|
# AxesMiddleware runs during the reponse phase. It does not conflict
|
||||||
|
# with middleware that runs in the request phase like
|
||||||
|
# django.middleware.cache.FetchFromCacheMiddleware.
|
||||||
'axes.middleware.AxesMiddleware',
|
'axes.middleware.AxesMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -75,6 +78,12 @@ Many people have different configurations for their development and production e
|
||||||
and running the application with misconfigured settings can prevent security features from working.
|
and running the application with misconfigured settings can prevent security features from working.
|
||||||
|
|
||||||
|
|
||||||
|
Version 8 breaking changes and upgrading from django-axes version 7
|
||||||
|
-------------------------------------------------------------------
|
||||||
|
|
||||||
|
Some database related utility functions have moved from ``axes.helpers`` to ``axes.handlers.database`` module and under the ``axes.handlers.database.AxesDatabaseHandler`` class.
|
||||||
|
|
||||||
|
|
||||||
Version 7 breaking changes and upgrading from django-axes version 6
|
Version 7 breaking changes and upgrading from django-axes version 6
|
||||||
-------------------------------------------------------------------
|
-------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,13 @@ The following ``settings.py`` options are available for customizing Axes behavio
|
||||||
+======================================================+==============================================+===========================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================+
|
+======================================================+==============================================+===========================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================+
|
||||||
| AXES_ENABLED | True | Enable or disable Axes plugin functionality, for example in test runner setup |
|
| AXES_ENABLED | True | Enable or disable Axes plugin functionality, for example in test runner setup |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_FAILURE_LIMIT | 3 | The integer number of login attempts allowed before a record is created for the failed logins. This can also be a callable or a dotted path to callable that returns an integer and all of the following are valid: ``AXES_FAILURE_LIMIT = 42``, ``AXES_FAILURE_LIMIT = lambda *args: 42``, and ``AXES_FAILURE_LIMIT = 'project.app.get_login_failure_limit'``. |
|
| AXES_FAILURE_LIMIT | 3 | The integer number of login attempts allowed before the request is considered locked. This can also be a callable or a dotted path to callable that returns an integer and all of the following are valid: ``AXES_FAILURE_LIMIT = 42``, ``AXES_FAILURE_LIMIT = lambda *args: 42``, and ``AXES_FAILURE_LIMIT = 'project.app.get_login_failure_limit'``. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_LOCK_OUT_AT_FAILURE | True | After the number of allowed login attempts are exceeded, should we lock out this IP (and optional user agent)? |
|
| AXES_LOCK_OUT_AT_FAILURE | True | After the number of allowed login attempts are exceeded, should we lock out this IP (and optional user agent)? |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_COOLOFF_TIME | None | If set, defines 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 the cool-off period after which old failed login attempts are cleared. If ``None``, lockout is permanent until attempts are manually reset. Can be set to a Python timedelta object, an integer, a float, a callable, or a string path to a callable that takes the request as argument. If an integer or float, this is interpreted as hours (``1`` is 1 hour, ``0.5`` is 30 minutes, ``1.7`` is 6120 seconds). ``timedelta`` is recommended for clarity. See also ``AXES_USE_ATTEMPT_EXPIRATION`` for rolling-window behavior. |
|
||||||
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
|
| AXES_USE_ATTEMPT_EXPIRATION | False | If ``True``, changes ``AXES_COOLOFF_TIME`` to a rolling window where each failed attempt expires individually after the cool-off time. This enables policies like "3 failed login attempts per 15 minutes". If ``False``, ``AXES_COOLOFF_TIME`` acts as an inactivity period where attempts are cleared only after no new failures occur within the cool-off limit. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_ONLY_ADMIN_SITE | False | If ``True``, lock is only enabled for admin site. Admin site is determined by checking request path against the path of ``"admin:index"`` view. If admin urls are not registered in current urlconf, all requests will not be locked. |
|
| AXES_ONLY_ADMIN_SITE | False | If ``True``, lock is only enabled for admin site. Admin site is determined by checking request path against the path of ``"admin:index"`` view. If admin urls are not registered in current urlconf, all requests will not be locked. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
|
|
@ -47,15 +49,15 @@ The following ``settings.py`` options are available for customizing Axes behavio
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_VERBOSE | True | If ``True``, you'll see slightly more logging for Axes. |
|
| AXES_VERBOSE | True | If ``True``, you'll see slightly more logging for Axes. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_USERNAME_FORM_FIELD | 'username' | The name of the form field that contains your users usernames. |
|
| AXES_USERNAME_FORM_FIELD | 'settings.AUTH_USER_MODEL.USERNAME_FIELD' | The name of the form field that contains your users usernames. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_USERNAME_CALLABLE | None | A callable or a string path to callable that takes two arguments for user lookups: ``def get_username(request: HttpRequest, credentials: dict) -> str: ...``. This can be any callable such as ``AXES_USERNAME_CALLABLE = lambda request, credentials: 'username'`` or a full Python module path to callable such as ``AXES_USERNAME_CALLABLE = 'example.get_username``. The ``request`` is a HttpRequest like object and the ``credentials`` is a dictionary like object. ``credentials`` are the ones that were passed to Django ``authenticate()`` in the login flow. If no function is supplied, Axes fetches the username from the ``credentials`` or ``request.POST`` dictionaries based on ``AXES_USERNAME_FORM_FIELD``. |
|
| AXES_USERNAME_CALLABLE | None | A callable or a string path to callable that takes two arguments for user lookups: ``def get_username(request: HttpRequest, credentials: dict) -> str: ...``. This can be any callable such as ``AXES_USERNAME_CALLABLE = lambda request, credentials: 'username'`` or a full Python module path to callable such as ``AXES_USERNAME_CALLABLE = 'example.get_username``. The ``request`` is a HttpRequest like object and the ``credentials`` is a dictionary like object. ``credentials`` are the ones that were passed to Django ``authenticate()`` in the login flow. If no function is supplied, Axes fetches the username from the ``credentials`` or ``request.POST`` dictionaries based on ``AXES_USERNAME_FORM_FIELD``. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_WHITELIST_CALLABLE | None | A callable or a string path to callable that takes two arguments for whitelisting determination and returns True, if user should be whitelisted: ``def is_whitelisted(request: HttpRequest, credentials: dict) -> bool: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. |
|
| AXES_WHITELIST_CALLABLE | None | A callable or a string path to callable that takes two arguments for whitelisting determination and returns True, if user should be whitelisted: ``def is_whitelisted(request: HttpRequest, credentials: dict) -> bool: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_LOCKOUT_CALLABLE | None | A callable or a string path to callable that takes 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_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_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_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_PASSWORD_FORM_FIELD | 'password' | The name of the form or credentials field that contains your users password. |
|
| AXES_PASSWORD_FORM_FIELD | 'password' | The name of the form or credentials field that contains your users password. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
|
|
@ -81,11 +83,28 @@ The following ``settings.py`` options are available for customizing Axes behavio
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_HTTP_RESPONSE_CODE | 429 | Sets the http response code returned when ``AXES_FAILURE_LIMIT`` is reached. For example: ``AXES_HTTP_RESPONSE_CODE = 403`` |
|
| AXES_HTTP_RESPONSE_CODE | 429 | Sets the http response code returned when ``AXES_FAILURE_LIMIT`` is reached. For example: ``AXES_HTTP_RESPONSE_CODE = 403`` |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT | True | If ``True``, a failed login attempt during lockout will reset the cool off period. |
|
| AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT | True | If ``True``, any failed login attempt during lockout resets the cool-off timer to ``now() + AXES_COOLOFF_TIME``. Repeated failed attempts keep extending the lockout period. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_LOCKOUT_PARAMETERS | ["ip_address"] | A list of parameters that Axes uses to lock out users. It can also be callable, which takes an http request or AccesAttempt object and credentials and returns a list of parameters. Each parameter can be a string (a single parameter) or a list of strings (a combined parameter). For example, if you configure ``AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]``, axes will block clients by ip and/or username and user agent combination. See :ref:`customizing-lockout-parameters` for more details. |
|
| AXES_LOCKOUT_PARAMETERS | ["ip_address"] | A list of parameters that Axes uses to lock out users. It can also be callable, which takes an http request or AccesAttempt object and credentials and returns a list of parameters. Each parameter can be a string (a single parameter) or a list of strings (a combined parameter). For example, if you configure ``AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]``, axes will block clients by ip and/or username and user agent combination. See :ref:`customizing-lockout-parameters` for more details. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
|
|
||||||
|
**Common configurations**
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
# Classic: 3 failures -> 30 min lockout
|
||||||
|
AXES_FAILURE_LIMIT = 3
|
||||||
|
AXES_COOLOFF_TIME = timedelta(minutes=30)
|
||||||
|
|
||||||
|
# Rolling window: max 5 failures in any 15-minute period
|
||||||
|
AXES_FAILURE_LIMIT = 5
|
||||||
|
AXES_COOLOFF_TIME = timedelta(minutes=15)
|
||||||
|
AXES_USE_ATTEMPT_EXPIRATION = True
|
||||||
|
|
||||||
|
# Hard lockout (manual reset only)
|
||||||
|
AXES_FAILURE_LIMIT = 5
|
||||||
|
AXES_COOLOFF_TIME = None
|
||||||
|
|
||||||
The configuration option precedences for the access attempt monitoring are:
|
The configuration option precedences for the access attempt monitoring are:
|
||||||
|
|
||||||
1. Default: only use IP address.
|
1. Default: only use IP address.
|
||||||
|
|
@ -109,6 +128,8 @@ following settings to suit your set up to correctly resolve client IP addresses:
|
||||||
* ``AXES_IPWARE_META_PRECEDENCE_ORDER``: The names of ``request.META`` attributes as a tuple of strings
|
* ``AXES_IPWARE_META_PRECEDENCE_ORDER``: The names of ``request.META`` attributes as a tuple of strings
|
||||||
to check to get the client IP address. Check the Django documentation for header naming conventions.
|
to check to get the client IP address. Check the Django documentation for header naming conventions.
|
||||||
Default: ``IPWARE_META_PRECEDENCE_ORDER`` setting if set, else ``('REMOTE_ADDR', )``
|
Default: ``IPWARE_META_PRECEDENCE_ORDER`` setting if set, else ``('REMOTE_ADDR', )``
|
||||||
|
* ``AXES_IPWARE_PROXY_ORDER``: The order in which to evaluate IP addresses from proxy headers when multiple IPs are present
|
||||||
|
in the header chain. Must be either ``"left-most"`` or ``"right-most"``. **Default:** ``"left-most"``
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
For reverse proxies or e.g. Heroku, you might also want to fetch IP addresses from a HTTP header such as ``X-Forwarded-For``. To configure this, you can fetch IPs through the ``HTTP_X_FORWARDED_FOR`` key from the ``request.META`` property which contains all the HTTP headers in Django:
|
For reverse proxies or e.g. Heroku, you might also want to fetch IP addresses from a HTTP header such as ``X-Forwarded-For``. To configure this, you can fetch IPs through the ``HTTP_X_FORWARDED_FOR`` key from the ``request.META`` property which contains all the HTTP headers in Django:
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ An example of usage could be e.g. a custom view for processing lockouts.
|
||||||
|
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
|
|
||||||
def lockout(request, credentials, *args, **kwargs):
|
def lockout(request, response, credentials, *args, **kwargs):
|
||||||
return JsonResponse({"status": "Locked out due to too many login failures"}, status=403)
|
return JsonResponse({"status": "Locked out due to too many login failures"}, status=403)
|
||||||
|
|
||||||
``settings.py``::
|
``settings.py``::
|
||||||
|
|
@ -188,7 +188,7 @@ Example ``AXES_LOCKOUT_PARAMETERS`` configuration:
|
||||||
|
|
||||||
AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]
|
AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]
|
||||||
|
|
||||||
This way, axes will lock out users using ip_address and/or combination of username and user agent
|
This way, axes will lock out users using ip_address or combination of username and user_agent
|
||||||
|
|
||||||
Example of callable ``AXES_LOCKOUT_PARAMETERS``:
|
Example of callable ``AXES_LOCKOUT_PARAMETERS``:
|
||||||
|
|
||||||
|
|
@ -213,7 +213,7 @@ Example of callable ``AXES_LOCKOUT_PARAMETERS``:
|
||||||
|
|
||||||
AXES_LOCKOUT_PARAMETERS = "example.utils.get_lockout_parameters"
|
AXES_LOCKOUT_PARAMETERS = "example.utils.get_lockout_parameters"
|
||||||
|
|
||||||
This way, if client ip_address is localhost, axes will lockout client only by username. In other case, axes will lockout client by username and/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 or ip_address.
|
||||||
|
|
||||||
Customizing client ip address lookups
|
Customizing client ip address lookups
|
||||||
-------------------------------------
|
-------------------------------------
|
||||||
|
|
|
||||||
9
docs/_static/css/custom_theme.css
vendored
Normal file
9
docs/_static/css/custom_theme.css
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
@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
|
https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sphinx_rtd_theme
|
# import sphinx_rtd_theme
|
||||||
from pkg_resources import get_distribution
|
from importlib.metadata import version as get_version
|
||||||
|
|
||||||
import django
|
import django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
@ -25,7 +25,7 @@ description = ("Keep track of failed login attempts in Django-powered sites.",)
|
||||||
|
|
||||||
# Add any Sphinx extension module names here, as strings.
|
# Add any Sphinx extension module names here, as strings.
|
||||||
# They can be extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
# They can be extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
||||||
extensions = ["sphinx.ext.autodoc"]
|
extensions = ["sphinx_rtd_theme","sphinx.ext.autodoc"]
|
||||||
|
|
||||||
# Add any paths that contain templates here, relative to this directory.
|
# Add any paths that contain templates here, relative to this directory.
|
||||||
templates_path = ["_templates"]
|
templates_path = ["_templates"]
|
||||||
|
|
@ -43,7 +43,7 @@ copyright = "2016, Jazzband"
|
||||||
author = "Jazzband"
|
author = "Jazzband"
|
||||||
|
|
||||||
# The full version, including alpha/beta/rc tags.
|
# The full version, including alpha/beta/rc tags.
|
||||||
release = get_distribution("django-axes").version
|
release = get_version("django-axes")
|
||||||
|
|
||||||
# The short X.Y version.
|
# The short X.Y version.
|
||||||
version = ".".join(release.split(".")[:2])
|
version = ".".join(release.split(".")[:2])
|
||||||
|
|
@ -71,8 +71,10 @@ todo_include_todos = False
|
||||||
# a list of builtin themes.
|
# a list of builtin themes.
|
||||||
html_theme = "sphinx_rtd_theme"
|
html_theme = "sphinx_rtd_theme"
|
||||||
|
|
||||||
|
html_style = "css/custom_theme.css"
|
||||||
|
|
||||||
# Add any paths that contain custom themes here, relative to this directory.
|
# Add any paths that contain custom themes here, relative to this directory.
|
||||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
# html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||||
|
|
||||||
# Add any paths that contain custom static files (such as style sheets) here,
|
# Add any paths that contain custom static files (such as style sheets) here,
|
||||||
# relative to this directory. They are copied after the builtin static files,
|
# relative to this directory. They are copied after the builtin static files,
|
||||||
|
|
|
||||||
2
mypy.ini
2
mypy.ini
|
|
@ -1,5 +1,5 @@
|
||||||
[mypy]
|
[mypy]
|
||||||
python_version = 3.8
|
python_version = 3.14
|
||||||
ignore_missing_imports = True
|
ignore_missing_imports = True
|
||||||
|
|
||||||
[mypy-axes.migrations.*]
|
[mypy-axes.migrations.*]
|
||||||
|
|
|
||||||
|
|
@ -10,35 +10,35 @@ DJANGO_SETTINGS_MODULE = "tests.settings"
|
||||||
legacy_tox_ini = """
|
legacy_tox_ini = """
|
||||||
[tox]
|
[tox]
|
||||||
envlist =
|
envlist =
|
||||||
py{38,39,310,311,312}-dj42
|
py{310,311,312}-dj42
|
||||||
py{310,311,312}-dj50
|
py{310,311,312,313}-dj52
|
||||||
py{310,311,312}-dj51
|
py{312,313,314}-dj60
|
||||||
py311-djmain
|
py314-djmain
|
||||||
py311-djqa
|
py314-djqa
|
||||||
|
|
||||||
[gh-actions]
|
[gh-actions]
|
||||||
python =
|
python =
|
||||||
3.8: py38
|
|
||||||
3.9: py39
|
|
||||||
3.10: py310
|
3.10: py310
|
||||||
3.11: py311
|
3.11: py311
|
||||||
3.12: py312
|
3.12: py312
|
||||||
|
3.13: py313
|
||||||
|
3.14: py314
|
||||||
|
|
||||||
[gh-actions:env]
|
[gh-actions:env]
|
||||||
DJANGO =
|
DJANGO =
|
||||||
4.2: dj42
|
4.2: dj42
|
||||||
5.0: dj50
|
5.2: dj52
|
||||||
5.1: dj51
|
6.0: dj60
|
||||||
main: djmain
|
main: djmain
|
||||||
qa: djqa
|
qa: djqa
|
||||||
|
|
||||||
# Normal test environment runs pytest which orchestrates other tools
|
# Normal test environment runs pytest which orchestrates other tools
|
||||||
[testenv]
|
[testenv]
|
||||||
deps =
|
deps =
|
||||||
-r requirements-test.txt
|
-r requirements.txt
|
||||||
dj32: django>=3.2,<3.3
|
dj42: django>=4.2,<4.3
|
||||||
dj42: django>=4.1,<4.2
|
dj52: django>=5.2,<5.3
|
||||||
dj50: django>=5.0,<5.1
|
dj60: django>=6.0,<6.1
|
||||||
djmain: https://github.com/django/django/archive/main.tar.gz
|
djmain: https://github.com/django/django/archive/main.tar.gz
|
||||||
usedevelop = true
|
usedevelop = true
|
||||||
commands = pytest
|
commands = pytest
|
||||||
|
|
@ -51,10 +51,11 @@ ignore_errors =
|
||||||
djmain: True
|
djmain: True
|
||||||
|
|
||||||
# QA runs type checks, linting, and code formatting checks
|
# QA runs type checks, linting, and code formatting checks
|
||||||
[testenv:py312-djqa]
|
[testenv:py314-djqa]
|
||||||
deps = -r requirements-qa.txt
|
stoponfail = false
|
||||||
|
deps = -r requirements.txt
|
||||||
commands =
|
commands =
|
||||||
mypy axes
|
mypy axes
|
||||||
prospector
|
prospector axes
|
||||||
black -t py38 --check --diff axes
|
black --check --diff axes
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
black==24.8.0
|
|
||||||
mypy==1.11.2
|
|
||||||
prospector==1.10.3
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
-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,5 +1,12 @@
|
||||||
-e .
|
-e .
|
||||||
-r requirements-qa.txt
|
black==26.3.1
|
||||||
-r requirements-test.txt
|
coverage==7.13.5
|
||||||
sphinx_rtd_theme==2.0.0
|
django-ipware>=3
|
||||||
tox==4.21.0
|
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.50.1
|
||||||
|
|
|
||||||
12
setup.py
12
setup.py
|
|
@ -35,9 +35,9 @@ setup(
|
||||||
package_dir={"axes": "axes"},
|
package_dir={"axes": "axes"},
|
||||||
use_scm_version=True,
|
use_scm_version=True,
|
||||||
setup_requires=["setuptools_scm"],
|
setup_requires=["setuptools_scm"],
|
||||||
python_requires=">=3.7",
|
python_requires=">=3.10",
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"django>=3.2",
|
"django>=4.2",
|
||||||
"asgiref>=3.6.0",
|
"asgiref>=3.6.0",
|
||||||
],
|
],
|
||||||
extras_require={
|
extras_require={
|
||||||
|
|
@ -51,19 +51,19 @@ setup(
|
||||||
"Environment :: Plugins",
|
"Environment :: Plugins",
|
||||||
"Framework :: Django",
|
"Framework :: Django",
|
||||||
"Framework :: Django :: 4.2",
|
"Framework :: Django :: 4.2",
|
||||||
"Framework :: Django :: 5.0",
|
"Framework :: Django :: 5.2",
|
||||||
"Framework :: Django :: 5.1",
|
"Framework :: Django :: 6.0",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"Intended Audience :: System Administrators",
|
"Intended Audience :: System Administrators",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
"Operating System :: OS Independent",
|
"Operating System :: OS Independent",
|
||||||
"Programming Language :: Python",
|
"Programming Language :: Python",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.8",
|
|
||||||
"Programming Language :: Python :: 3.9",
|
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: 3.14",
|
||||||
"Programming Language :: Python :: Implementation :: CPython",
|
"Programming Language :: Python :: Implementation :: CPython",
|
||||||
"Topic :: Internet :: Log Analysis",
|
"Topic :: Internet :: Log Analysis",
|
||||||
"Topic :: Security",
|
"Topic :: Security",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from string import ascii_letters, digits
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.base_user import AbstractBaseUser
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
|
||||||
|
|
@ -129,3 +129,24 @@ class ConfCheckTestCase(AxesTestCase):
|
||||||
def test_valid_callable(self):
|
def test_valid_callable(self):
|
||||||
warnings = run_checks()
|
warnings = run_checks()
|
||||||
self.assertEqual(warnings, [])
|
self.assertEqual(warnings, [])
|
||||||
|
|
||||||
|
def test_missing_settings_no_error(self):
|
||||||
|
warnings = run_checks()
|
||||||
|
self.assertEqual(warnings, [])
|
||||||
|
|
||||||
|
|
||||||
|
class LockoutParametersCheckTestCase(AxesTestCase):
|
||||||
|
@override_settings(AXES_LOCKOUT_PARAMETERS=["ip_address", "username"])
|
||||||
|
def test_valid_configuration(self):
|
||||||
|
warnings = run_checks()
|
||||||
|
self.assertEqual(warnings, [])
|
||||||
|
|
||||||
|
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "user_agent"])
|
||||||
|
def test_invalid_configuration(self):
|
||||||
|
warnings = run_checks()
|
||||||
|
warning = Warning(
|
||||||
|
msg=Messages.LOCKOUT_PARAMETERS_INVALID,
|
||||||
|
hint=Hints.LOCKOUT_PARAMETERS_INVALID,
|
||||||
|
id=Codes.LOCKOUT_PARAMETERS_INVALID,
|
||||||
|
)
|
||||||
|
self.assertEqual(warnings, [warning])
|
||||||
|
|
|
||||||
45
tests/test_conf.py
Normal file
45
tests/test_conf.py
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
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,18 +1,20 @@
|
||||||
from platform import python_implementation
|
from platform import python_implementation
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
from datetime import datetime, timezone as dt_timezone
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from axes.handlers.database import AxesDatabaseHandler
|
||||||
|
from axes.models import AccessAttempt, AccessLog, AccessFailureLog, AccessAttemptExpiration
|
||||||
|
|
||||||
from pytest import mark
|
from pytest import mark
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.test import override_settings
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
|
||||||
from django.utils.timezone import timedelta
|
from django.utils.timezone import timedelta
|
||||||
|
|
||||||
from axes.conf import settings
|
from axes.conf import settings
|
||||||
from axes.handlers.proxy import AxesProxyHandler
|
from axes.handlers.proxy import AxesProxyHandler
|
||||||
from axes.helpers import get_client_str
|
from axes.helpers import get_client_str
|
||||||
from axes.models import AccessAttempt, AccessLog, AccessFailureLog
|
|
||||||
from tests.base import AxesTestCase
|
from tests.base import AxesTestCase
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -567,3 +569,170 @@ class AxesTestHandlerTestCase(AxesHandlerBaseTestCase):
|
||||||
|
|
||||||
def test_handler_get_failures(self):
|
def test_handler_get_failures(self):
|
||||||
self.assertEqual(0, AxesProxyHandler.get_failures(self.request, {}))
|
self.assertEqual(0, AxesProxyHandler.get_failures(self.request, {}))
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(AXES_HANDLER="axes.handlers.database.AxesDatabaseHandler", AXES_COOLOFF_TIME=timezone.timedelta(seconds=10))
|
||||||
|
class AxesDatabaseHandlerExpirationFlagTestCase(AxesTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.handler = AxesDatabaseHandler()
|
||||||
|
self.mock_request = MagicMock()
|
||||||
|
self.mock_credentials = None
|
||||||
|
|
||||||
|
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=True)
|
||||||
|
@patch("axes.handlers.database.log")
|
||||||
|
@patch("axes.models.AccessAttempt.objects.filter")
|
||||||
|
@patch("django.utils.timezone.now")
|
||||||
|
def test_clean_expired_user_attempts_expiration_true(self, mock_now, mock_filter, mock_log):
|
||||||
|
mock_now.return_value = datetime(2025, 1, 1, tzinfo=dt_timezone.utc)
|
||||||
|
mock_qs = MagicMock()
|
||||||
|
mock_filter.return_value = mock_qs
|
||||||
|
mock_qs.delete.return_value = (3, None)
|
||||||
|
|
||||||
|
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
|
||||||
|
mock_filter.assert_called_once_with(expiration__expires_at__lte=mock_now.return_value)
|
||||||
|
mock_qs.delete.assert_called_once()
|
||||||
|
mock_log.info.assert_called_with(
|
||||||
|
"AXES: Cleaned up %s expired access attempts from database that expiry were older than %s",
|
||||||
|
3,
|
||||||
|
mock_now.return_value,
|
||||||
|
)
|
||||||
|
self.assertEqual(count, 3)
|
||||||
|
|
||||||
|
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=True)
|
||||||
|
@patch("axes.handlers.database.log")
|
||||||
|
def test_clean_expired_user_attempts_expiration_true_with_complete_deletion(self, mock_log):
|
||||||
|
AccessAttempt.objects.all().delete()
|
||||||
|
dummy_attempt = AccessAttempt.objects.create(
|
||||||
|
username="test_user",
|
||||||
|
ip_address="192.168.1.1",
|
||||||
|
failures_since_start=1,
|
||||||
|
user_agent="test_agent",
|
||||||
|
)
|
||||||
|
dummy_attempt.expiration = AccessAttemptExpiration.objects.create(
|
||||||
|
access_attempt=dummy_attempt,
|
||||||
|
expires_at=timezone.now() - timezone.timedelta(days=1) # Set to expire in the past
|
||||||
|
)
|
||||||
|
|
||||||
|
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
|
||||||
|
mock_log.info.assert_called_once()
|
||||||
|
|
||||||
|
# comparing count=2, as one is the dummy attempt and one is the expiration
|
||||||
|
self.assertEqual(count, 2)
|
||||||
|
self.assertEqual(
|
||||||
|
AccessAttempt.objects.count(), 0
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
AccessAttemptExpiration.objects.count(), 0
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=True)
|
||||||
|
@patch("axes.handlers.database.log")
|
||||||
|
def test_clean_expired_user_attempts_expiration_true_with_partial_deletion(self, mock_log):
|
||||||
|
|
||||||
|
attempt_not_expired = AccessAttempt.objects.create(
|
||||||
|
username="test_user",
|
||||||
|
ip_address="192.168.1.1",
|
||||||
|
failures_since_start=1,
|
||||||
|
user_agent="test_agent",
|
||||||
|
)
|
||||||
|
attempt_not_expired.expiration = AccessAttemptExpiration.objects.create(
|
||||||
|
access_attempt=attempt_not_expired,
|
||||||
|
expires_at=timezone.now() + timezone.timedelta(days=1) # Set to expire in the future
|
||||||
|
)
|
||||||
|
|
||||||
|
attempt_expired = AccessAttempt.objects.create(
|
||||||
|
username="test_user_2",
|
||||||
|
ip_address="192.168.1.2",
|
||||||
|
failures_since_start=1,
|
||||||
|
user_agent="test_agent",
|
||||||
|
)
|
||||||
|
attempt_expired.expiration = AccessAttemptExpiration.objects.create(
|
||||||
|
access_attempt=attempt_expired,
|
||||||
|
expires_at=timezone.now() - timezone.timedelta(days=1) # Set to expire in the past
|
||||||
|
)
|
||||||
|
|
||||||
|
access_attempt_count = AccessAttempt.objects.count()
|
||||||
|
access_attempt_expiration_count = AccessAttemptExpiration.objects.count()
|
||||||
|
|
||||||
|
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
|
||||||
|
mock_log.info.assert_called_once()
|
||||||
|
|
||||||
|
# comparing count=2, as one is the dummy attempt and one is the expiration
|
||||||
|
self.assertEqual(count, 2)
|
||||||
|
self.assertEqual(
|
||||||
|
AccessAttempt.objects.count(), access_attempt_count - 1
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
AccessAttemptExpiration.objects.count(), access_attempt_expiration_count - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=True)
|
||||||
|
@patch("axes.handlers.database.log")
|
||||||
|
def test_clean_expired_user_attempts_expiration_true_with_no_deletion(self, mock_log):
|
||||||
|
|
||||||
|
attempt_not_expired_1 = AccessAttempt.objects.create(
|
||||||
|
username="test_user",
|
||||||
|
ip_address="192.168.1.1",
|
||||||
|
failures_since_start=1,
|
||||||
|
user_agent="test_agent",
|
||||||
|
)
|
||||||
|
attempt_not_expired_1.expiration = AccessAttemptExpiration.objects.create(
|
||||||
|
access_attempt=attempt_not_expired_1,
|
||||||
|
expires_at=timezone.now() + timezone.timedelta(days=1) # Set to expire in the future
|
||||||
|
)
|
||||||
|
|
||||||
|
attempt_not_expired_2 = AccessAttempt.objects.create(
|
||||||
|
username="test_user_2",
|
||||||
|
ip_address="192.168.1.2",
|
||||||
|
failures_since_start=1,
|
||||||
|
user_agent="test_agent",
|
||||||
|
)
|
||||||
|
attempt_not_expired_2.expiration = AccessAttemptExpiration.objects.create(
|
||||||
|
access_attempt=attempt_not_expired_2,
|
||||||
|
expires_at=timezone.now() + timezone.timedelta(days=2) # Set to expire in the future
|
||||||
|
)
|
||||||
|
|
||||||
|
access_attempt_count = AccessAttempt.objects.count()
|
||||||
|
access_attempt_expiration_count = AccessAttemptExpiration.objects.count()
|
||||||
|
|
||||||
|
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
|
||||||
|
mock_log.info.assert_called_once()
|
||||||
|
|
||||||
|
# comparing count=2, as one is the dummy attempt and one is the expiration
|
||||||
|
self.assertEqual(count, 0)
|
||||||
|
self.assertEqual(
|
||||||
|
AccessAttempt.objects.count(), access_attempt_count
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
AccessAttemptExpiration.objects.count(), access_attempt_expiration_count
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=False)
|
||||||
|
@patch("axes.handlers.database.log")
|
||||||
|
@patch("axes.handlers.database.get_cool_off_threshold")
|
||||||
|
@patch("axes.models.AccessAttempt.objects.filter")
|
||||||
|
def test_clean_expired_user_attempts_expiration_false(self, mock_filter, mock_get_threshold, mock_log):
|
||||||
|
mock_get_threshold.return_value = "fake-threshold"
|
||||||
|
mock_qs = MagicMock()
|
||||||
|
mock_filter.return_value = mock_qs
|
||||||
|
mock_qs.delete.return_value = (2, None)
|
||||||
|
|
||||||
|
count = self.handler.clean_expired_user_attempts(request=self.mock_request, credentials=None)
|
||||||
|
mock_filter.assert_called_once_with(attempt_time__lte="fake-threshold")
|
||||||
|
mock_qs.delete.assert_called_once()
|
||||||
|
mock_log.info.assert_called_with(
|
||||||
|
"AXES: Cleaned up %s expired access attempts from database that were older than %s",
|
||||||
|
2,
|
||||||
|
"fake-threshold",
|
||||||
|
)
|
||||||
|
self.assertEqual(count, 2)
|
||||||
|
|
||||||
|
@override_settings(AXES_COOLOFF_TIME=None)
|
||||||
|
@patch("axes.handlers.database.log")
|
||||||
|
def test_clean_expired_user_attempts_no_cooloff(self, mock_log):
|
||||||
|
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
|
||||||
|
mock_log.debug.assert_called_with(
|
||||||
|
"AXES: Skipping clean for expired access attempts because no AXES_COOLOFF_TIME is configured"
|
||||||
|
)
|
||||||
|
self.assertEqual(count, 0)
|
||||||
|
|
|
||||||
|
|
@ -1013,9 +1013,16 @@ def mock_get_lockout_response(request, credentials):
|
||||||
return HttpResponse(status=400)
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
|
||||||
|
def mock_get_lockout_response_with_original_response_param(
|
||||||
|
request, response, credentials
|
||||||
|
):
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
|
||||||
class AxesLockoutTestCase(AxesTestCase):
|
class AxesLockoutTestCase(AxesTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.request = HttpRequest()
|
self.request = HttpRequest()
|
||||||
|
self.response = HttpResponse()
|
||||||
self.credentials = dict()
|
self.credentials = dict()
|
||||||
|
|
||||||
def test_get_lockout_response(self):
|
def test_get_lockout_response(self):
|
||||||
|
|
@ -1039,6 +1046,20 @@ class AxesLockoutTestCase(AxesTestCase):
|
||||||
response = get_lockout_response(self.request, self.credentials)
|
response = get_lockout_response(self.request, self.credentials)
|
||||||
self.assertEqual(400, response.status_code)
|
self.assertEqual(400, response.status_code)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
AXES_LOCKOUT_CALLABLE=mock_get_lockout_response_with_original_response_param
|
||||||
|
)
|
||||||
|
def test_get_lockout_response_override_callable_with_original_response_param(self):
|
||||||
|
response = get_lockout_response(self.request, self.response, self.credentials)
|
||||||
|
self.assertEqual(400, response.status_code)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
AXES_LOCKOUT_CALLABLE="tests.test_helpers.mock_get_lockout_response_with_original_response_param"
|
||||||
|
)
|
||||||
|
def test_get_lockout_response_override_path_with_original_response_param(self):
|
||||||
|
response = get_lockout_response(self.request, self.response, self.credentials)
|
||||||
|
self.assertEqual(400, response.status_code)
|
||||||
|
|
||||||
@override_settings(AXES_LOCKOUT_CALLABLE=42)
|
@override_settings(AXES_LOCKOUT_CALLABLE=42)
|
||||||
def test_get_lockout_response_override_invalid(self):
|
def test_get_lockout_response_override_invalid(self):
|
||||||
with self.assertRaises(TypeError):
|
with self.assertRaises(TypeError):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue