mirror of
https://github.com/jazzband/django-axes.git
synced 2026-03-17 06:40:24 +00:00
Compare commits
No commits in common. "master" and "6.0.0b4" have entirely different histories.
54 changed files with 320 additions and 1803 deletions
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -1,33 +0,0 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve django-axes
|
||||
title: 'BUG: Short description of the problem'
|
||||
labels: 'bug'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
4.
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Your environment**
|
||||
python version:
|
||||
django version:
|
||||
django-axes version:
|
||||
Operating system:
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
**Possible implementation**
|
||||
Not obligatory, but suggest an idea for implementing addition or change
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
|
@ -1,20 +0,0 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for django-axes
|
||||
title: 'FEATURE REQUEST: Short description of requested feature'
|
||||
labels: 'feature request'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
19
.github/PULL_REQUEST_TEMPLATE.md
vendored
19
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
|
@ -1,19 +0,0 @@
|
|||
# What does this PR do?
|
||||
|
||||
<!--
|
||||
Congratulations! You've made it this far! You're not quite done yet though.
|
||||
|
||||
Please replace this with a description of the change and which issue is fixed (if applicable). Please also include relevant motivation and context. List any dependencies (if any) that are required for this change.
|
||||
|
||||
Once you're done, someone will review your PR shortly. They may suggest changes to make the code even better.
|
||||
-->
|
||||
|
||||
<!-- Remove if not applicable -->
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
|
||||
## Before submitting
|
||||
- [ ] This PR fixes a typo or improves the docs (you can dismiss the other checks if that's the case).
|
||||
- [ ] Did you make sure to update the documentation with your changes?
|
||||
- [ ] Did you write any new necessary tests?
|
||||
8
.github/workflows/codeql.yml
vendored
8
.github/workflows/codeql.yml
vendored
|
|
@ -14,11 +14,11 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@v2
|
||||
# Override language selection by uncommenting this and choosing your languages
|
||||
# with:
|
||||
# languages: go, javascript, csharp, python, cpp, java
|
||||
|
|
@ -26,7 +26,7 @@ jobs:
|
|||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below).
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
|
@ -40,4 +40,4 @@ jobs:
|
|||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
|
|
|||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
|
|
@ -14,14 +14,14 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.12
|
||||
python-version: 3.8
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
|
|
@ -36,8 +36,8 @@ jobs:
|
|||
|
||||
- name: Upload packages to Jazzband
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
uses: pypa/gh-action-pypi-publish@master
|
||||
with:
|
||||
user: jazzband
|
||||
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
|
||||
repository-url: https://jazzband.co/projects/django-axes/upload
|
||||
repository_url: https://jazzband.co/projects/django-axes/upload
|
||||
|
|
|
|||
42
.github/workflows/test.yml
vendored
42
.github/workflows/test.yml
vendored
|
|
@ -11,32 +11,46 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 5
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
||||
django-version: ['4.2', '5.2', '6.0']
|
||||
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
|
||||
django-version: ['3.2', '4.1', '4.2']
|
||||
include:
|
||||
# Tox configuration for QA environment
|
||||
- python-version: '3.14'
|
||||
- python-version: '3.11'
|
||||
django-version: 'qa'
|
||||
# Django main
|
||||
- python-version: '3.14'
|
||||
- python-version: '3.11'
|
||||
django-version: 'main'
|
||||
experimental: true
|
||||
exclude:
|
||||
- python-version: '3.13'
|
||||
# PyPy 3.8
|
||||
- python-version: 'pypy-3.8'
|
||||
django-version: '3.2'
|
||||
experimental: true
|
||||
- python-version: 'pypy-3.8'
|
||||
django-version: '4.1'
|
||||
experimental: true
|
||||
- python-version: 'pypy-3.8'
|
||||
django-version: '4.2'
|
||||
- python-version: '3.9'
|
||||
django-version: '5.2'
|
||||
- python-version: '3.10'
|
||||
django-version: '6.0'
|
||||
experimental: true
|
||||
exclude:
|
||||
# Exclude Python 3.7 for Django 4.x and Django main
|
||||
- python-version: '3.7'
|
||||
django-version: '4.1'
|
||||
- python-version: '3.7'
|
||||
django-version: '4.2'
|
||||
- python-version: '3.7'
|
||||
django-version: 'main'
|
||||
# Exclude Python 3.11 for Django 3.2 and Django 4.0
|
||||
- python-version: '3.11'
|
||||
django-version: '6.0'
|
||||
django-version: '3.2'
|
||||
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
|
|
@ -46,7 +60,7 @@ jobs:
|
|||
echo "::set-output name=dir::$(pip cache dir)"
|
||||
|
||||
- name: Cache
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pip-cache.outputs.dir }}
|
||||
key:
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
# Read the Docs configuration file for Sphinx projects
|
||||
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||
version: 2
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3.11"
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
formats:
|
||||
- pdf
|
||||
- epub
|
||||
python:
|
||||
install:
|
||||
- requirements: requirements.txt
|
||||
221
CHANGES.rst
221
CHANGES.rst
|
|
@ -2,224 +2,37 @@
|
|||
Changes
|
||||
=======
|
||||
|
||||
8.3.1 (2026-02-11)
|
||||
------------------
|
||||
|
||||
- Fix configuration JSON serialization errors for Celery.
|
||||
[aleksihakli]
|
||||
6.0.0b4 (2023-05-13)
|
||||
--------------------
|
||||
|
||||
8.3.0 (2026-02-09)
|
||||
------------------
|
||||
|
||||
- Remove deprecated pkg_resources in favour of new importlib.
|
||||
[hugovk]
|
||||
|
||||
8.2.0 (2026-02-06)
|
||||
------------------
|
||||
|
||||
- Fix AttributeError when optional settings are undefined.
|
||||
[rodrigo.nogueira]
|
||||
- Fix circular import with custom user models.
|
||||
[rodrigo.nogueira]
|
||||
- Add unit tests for security check W006.
|
||||
[shayanTaki]
|
||||
|
||||
8.1.0 (2025-12-19)
|
||||
------------------
|
||||
|
||||
- Add Persion (fa) translations for django-axes.
|
||||
[AmirAli-BahramJerdi]
|
||||
- Add individual attempt expiry support.
|
||||
[kuldeepkhatke]
|
||||
- Add checks for missing ip_address in lockout params.
|
||||
[shayanTaki]
|
||||
- Add missing ``settings.AXES_IPWARE_PROXY_ORDER`` documentation.
|
||||
[ram98kgp]
|
||||
- Enhance ``get_lockout_response`` to receive original response as parameter.
|
||||
[mounirmesselmeni]
|
||||
- Update documentation.
|
||||
- Add Python 3.14 support.
|
||||
- Add Django 6.0 support.
|
||||
- Remove Python 3.9 support (EOL).
|
||||
- Remove Django 5.1 support (EOL).
|
||||
|
||||
8.0.0 (2025-05-10)
|
||||
------------------
|
||||
|
||||
- Move all database related logic to the default ``axes.handlers.database.AxesDatabaseHandler``.
|
||||
[nefrob]
|
||||
|
||||
7.1.0 (2025-04-23)
|
||||
------------------
|
||||
|
||||
- Provide credentials to expired credentials cleanup method.
|
||||
[parul-aro]
|
||||
- Update support matrix for Django 5.2.
|
||||
[mkniewallner]
|
||||
- Fix documentation.
|
||||
[chango-goat]
|
||||
|
||||
|
||||
7.0.2 (2025-02-19)
|
||||
------------------
|
||||
|
||||
- Fix documentation.
|
||||
[Jacobus-afk]
|
||||
- Default to using ``settings.AUTH_USER_MODEL.USERNAME_FIELD`` for resolving ``settings.AXES_USERNAME_FORM_FIELD`` if otherwise unset (previously "username").
|
||||
[amneher]
|
||||
|
||||
|
||||
7.0.1 (2024-12-02)
|
||||
------------------
|
||||
|
||||
- Add Python 3.13 support.
|
||||
[aleksihakli]
|
||||
- Deprecate Python 3.8 support.
|
||||
[aleksihakli]
|
||||
|
||||
|
||||
7.0.0 (2024-10-02)
|
||||
------------------
|
||||
|
||||
- Add support for dynamic cooloff time calculation from request. This is a breaking change. Please see `version 7 upgrade notes in the documentation <https://github.com/jazzband/django-axes/blob/4e89d72b92db044ff3f6b23ea2ab2e681211c98e/docs/2_installation.rst#version-7-breaking-changes-and-upgrading-from-django-axes-version-6>`_.
|
||||
[browniebroke]
|
||||
|
||||
|
||||
6.5.2 (2024-09-21)
|
||||
------------------
|
||||
|
||||
- Add test matrix support for Django 5.1.
|
||||
- Drop support for EOL Django 3.2.
|
||||
- Drop support for PyPy 3.10.
|
||||
|
||||
|
||||
6.5.1 (2024-07-01)
|
||||
------------------
|
||||
|
||||
- Make 0007_alter_accessattempt_unique_together.py migration backwards compatible.
|
||||
[hirotasoshu]
|
||||
|
||||
|
||||
6.5.0 (2024-06-11)
|
||||
------------------
|
||||
|
||||
- Add session hash to access log.
|
||||
[sevdog]
|
||||
|
||||
|
||||
6.4.0 (2024-03-04)
|
||||
------------------
|
||||
|
||||
- Add support for Python 3.12 and Django 5.0, drop support for Django 4.1.
|
||||
[aleksihakli]
|
||||
|
||||
|
||||
6.3.1 (2024-03-04)
|
||||
------------------
|
||||
|
||||
- Drop ``setuptools`` and ``pkg_resources`` dependencies.
|
||||
[Viicos]
|
||||
|
||||
|
||||
6.3.0 (2023-12-27)
|
||||
------------------
|
||||
|
||||
- Add async support to middleware.
|
||||
[Taikono-Himazin]
|
||||
|
||||
|
||||
6.2.0 (2023-12-08)
|
||||
------------------
|
||||
|
||||
- Update documentation.
|
||||
[funkybob]
|
||||
- Add new management command ``axes_reset_ip_username``.
|
||||
[p-l-]
|
||||
- Add French translations.
|
||||
[laulaz]
|
||||
- Avoid running data migration on incorrect databases.
|
||||
[christianbundy]
|
||||
|
||||
|
||||
6.1.1 (2023-08-01)
|
||||
------------------
|
||||
|
||||
- Fix ``TransactionManagementError`` when using the database handler
|
||||
with a custom database with for ``AccessAttempt`` or ``AccessFailureLog``.
|
||||
[hirotasoshu]
|
||||
|
||||
|
||||
6.1.0 (2023-07-30)
|
||||
------------------
|
||||
|
||||
- Set ``AXES_SENSITIVE_PARAMETERS`` default value to ``["username", "ip_address"]`` in addition to the ``AXES_PASSWORD_FORM_FIELD`` configuration flag.
|
||||
This masks the username and IP address fields by default in the logs when writing information about login attempts to the application logs.
|
||||
Reverting to old configuration default of ``[]`` can be done by setting ``AXES_SENSITIVE_PARAMETERS = []`` in the Django project settings file.
|
||||
[GitRon]
|
||||
- Improve documentation on GDPR and privacy notes and configuration flags.
|
||||
[GitRon]
|
||||
|
||||
|
||||
6.0.5 (2023-07-01)
|
||||
------------------
|
||||
|
||||
- Add Indonesion translation.
|
||||
[kiraware]
|
||||
|
||||
|
||||
6.0.4 (2023-06-22)
|
||||
------------------
|
||||
|
||||
- Remove unused methods from AxesStandaloneBackend.
|
||||
[314eter]
|
||||
|
||||
|
||||
6.0.3 (2023-06-18)
|
||||
------------------
|
||||
|
||||
- Add username to admin fieldsets.
|
||||
[sevdog]
|
||||
|
||||
|
||||
6.0.2 (2023-06-13)
|
||||
------------------
|
||||
|
||||
- Add Django system checks for validating callable import path settings.
|
||||
[iafisher]
|
||||
- Improve documentation.
|
||||
[hirotasoshu]
|
||||
- Improve repository issue and PR templates.
|
||||
[hirotasoshu]
|
||||
|
||||
|
||||
6.0.1 (2023-05-17)
|
||||
------------------
|
||||
|
||||
- Fine-tune CI pipelines and RTD build requirements.
|
||||
[aleksihakli]
|
||||
|
||||
|
||||
6.0.0 (2023-05-17)
|
||||
------------------
|
||||
|
||||
Version 6 is a breaking release. Please see the documentation for upgrade instructions.
|
||||
|
||||
- Deprecate Python 3.7 support.
|
||||
[aleksihakli]
|
||||
- Deprecate ``is_admin_site`` API call with misleading naming.
|
||||
[hirotasoshu]
|
||||
- Add ``AXES_LOCKOUT_PARAMETERS`` configuration flag that will supersede ``AXES_ONLY_USER_FAILURES``, ``AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP``, ``AXES_LOCK_OUT_BY_USER_OR_IP``, and ``AXES_USE_USER_AGENT`` configurations. Add deprecation warnings for old flags. See project documentation on RTD for update instructions.
|
||||
[hirotasoshu]
|
||||
- Improve translations.
|
||||
[hirotasoshu]
|
||||
|
||||
|
||||
6.0.0b3 (2023-05-01)
|
||||
--------------------
|
||||
|
||||
- Use Django ``cache.incr`` API for atomic cached failure counting
|
||||
[hirotasoshu, aleksihakli]
|
||||
|
||||
|
||||
6.0.0b2 (2023-04-28)
|
||||
--------------------
|
||||
|
||||
- Make ``django-ipware`` an optional dependency. Install it with e.g. ``pip install django-axes[ipware]`` package and extras specifier. [aleksihakli]
|
||||
- Deprecate and rename old configuration flags. Old flags will be removed in or after version ``6.1``. [aleksihakli]
|
||||
* ``AXES_PROXY_ORDER`` is now ``AXES_IPWARE_PROXY_ORDER``,
|
||||
* ``AXES_PROXY_COUNT`` is now ``AXES_IPWARE_PROXY_COUNT``,
|
||||
* ``AXES_PROXY_TRUSTED_IPS`` is now ``AXES_IPWARE_PROXY_TRUSTED_IPS``, and
|
||||
* ``AXES_META_PRECEDENCE_ORDER`` is now ``AXES_IPWARE_META_PRECEDENCE_ORDER``.
|
||||
|
||||
|
||||
6.0.0b1 (2023-04-25)
|
||||
--------------------
|
||||
|
||||
- Set 429 as the default lockout response code. [hirotasoshu]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -57,8 +57,8 @@ or alternatively use a fast and DDoS resistant cache implementation.
|
|||
Axes can be configured to monitor login attempts by
|
||||
IP address, username, user agent, or their combinations.
|
||||
|
||||
Axes supports cool off periods, IP address allow listing and block listing,
|
||||
user account allow listing, and other features for Django access management.
|
||||
Axes supports cool off periods, IP address whitelisting and blacklisting,
|
||||
user account whitelisting, and other features for Django access management.
|
||||
|
||||
|
||||
Documentation
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
from importlib.metadata import version
|
||||
try:
|
||||
from importlib.metadata import version # New in Python 3.8
|
||||
except ImportError:
|
||||
from pkg_resources import get_distribution # from setuptools, deprecated
|
||||
|
||||
__version__ = version("django-axes")
|
||||
__version__ = get_distribution("django-axes").version
|
||||
else:
|
||||
__version__ = version("django-axes")
|
||||
|
|
|
|||
|
|
@ -4,59 +4,26 @@ from django.utils.translation import gettext_lazy as _
|
|||
|
||||
from axes.conf import settings
|
||||
from axes.models import AccessAttempt, AccessLog, AccessFailureLog
|
||||
from axes.handlers.database import AxesDatabaseHandler
|
||||
|
||||
|
||||
class IsLockedOutFilter(admin.SimpleListFilter):
|
||||
title = _("Locked Out")
|
||||
parameter_name = "locked_out"
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
("yes", _("Yes")),
|
||||
("no", _("No")),
|
||||
)
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value() == "yes":
|
||||
return queryset.filter(
|
||||
failures_since_start__gte=settings.AXES_FAILURE_LIMIT
|
||||
)
|
||||
if self.value() == "no":
|
||||
return queryset.filter(failures_since_start__lt=settings.AXES_FAILURE_LIMIT)
|
||||
return queryset
|
||||
|
||||
|
||||
class AccessAttemptAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
list_display = (
|
||||
"attempt_time",
|
||||
"ip_address",
|
||||
"user_agent",
|
||||
"username",
|
||||
"path_info",
|
||||
"failures_since_start",
|
||||
]
|
||||
|
||||
if settings.AXES_USE_ATTEMPT_EXPIRATION:
|
||||
list_display.append("expiration")
|
||||
)
|
||||
|
||||
list_filter = ["attempt_time", "path_info"]
|
||||
|
||||
if isinstance(settings.AXES_FAILURE_LIMIT, int) and settings.AXES_FAILURE_LIMIT > 0:
|
||||
# This will only add the status field if AXES_FAILURE_LIMIT is set to a positive integer
|
||||
# Because callable failure limit requires scope of request object
|
||||
list_display.append("status")
|
||||
list_filter.append(IsLockedOutFilter) # type: ignore[arg-type]
|
||||
|
||||
search_fields = ["ip_address", "username", "user_agent", "path_info"]
|
||||
|
||||
date_hierarchy = "attempt_time"
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{"fields": ("username", "path_info", "failures_since_start", "expiration")},
|
||||
),
|
||||
(None, {"fields": ("path_info", "failures_since_start")}),
|
||||
(_("Form Data"), {"fields": ("get_data", "post_data")}),
|
||||
(_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
|
||||
)
|
||||
|
|
@ -71,34 +38,11 @@ class AccessAttemptAdmin(admin.ModelAdmin):
|
|||
"get_data",
|
||||
"post_data",
|
||||
"failures_since_start",
|
||||
"expiration",
|
||||
]
|
||||
|
||||
actions = ["cleanup_expired_attempts"]
|
||||
|
||||
@admin.action(description=_("Clean up expired attempts"))
|
||||
def cleanup_expired_attempts(self, request, queryset): # noqa
|
||||
count = self.handler.clean_expired_user_attempts(request=request)
|
||||
self.message_user(request, _(f"Cleaned up {count} expired access attempts."))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.handler = AxesDatabaseHandler()
|
||||
|
||||
def has_add_permission(self, request: HttpRequest) -> bool:
|
||||
return False
|
||||
|
||||
def expiration(self, obj: AccessAttempt):
|
||||
return obj.expiration.expires_at if hasattr(obj, "expiration") else _("Not set")
|
||||
|
||||
def status(self, obj: AccessAttempt):
|
||||
return (
|
||||
f"{settings.AXES_FAILURE_LIMIT - obj.failures_since_start} "
|
||||
+ _("Attempt Remaining")
|
||||
if obj.failures_since_start < settings.AXES_FAILURE_LIMIT
|
||||
else _("Locked Out")
|
||||
)
|
||||
|
||||
|
||||
class AccessLogAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
|
|
@ -117,7 +61,7 @@ class AccessLogAdmin(admin.ModelAdmin):
|
|||
date_hierarchy = "attempt_time"
|
||||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("username", "path_info")}),
|
||||
(None, {"fields": ("path_info",)}),
|
||||
(_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
|
||||
)
|
||||
|
||||
|
|
@ -152,7 +96,7 @@ class AccessFailureLogAdmin(admin.ModelAdmin):
|
|||
date_hierarchy = "attempt_time"
|
||||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("username", "path_info")}),
|
||||
(None, {"fields": ("path_info",)}),
|
||||
(_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -41,11 +41,9 @@ class AppConfig(apps.AppConfig):
|
|||
else:
|
||||
mode = "blocking by " + " or ".join(
|
||||
[
|
||||
(
|
||||
param
|
||||
if isinstance(param, str)
|
||||
else "combination of " + " and ".join(param)
|
||||
)
|
||||
param
|
||||
if isinstance(param, str)
|
||||
else "combination of " + " and ".join(param)
|
||||
for param in settings.AXES_LOCKOUT_PARAMETERS
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,26 +1,106 @@
|
|||
from logging import getLogger
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from django.db.models import QuerySet
|
||||
from django.http import HttpRequest
|
||||
from django.utils.timezone import datetime, now
|
||||
|
||||
from axes.helpers import get_cool_off
|
||||
from axes.conf import settings
|
||||
from axes.helpers import get_client_username, get_client_parameters, get_cool_off
|
||||
from axes.models import AccessAttempt
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
def get_cool_off_threshold(request: Optional[HttpRequest] = None) -> datetime:
|
||||
def get_cool_off_threshold(attempt_time: Optional[datetime] = None) -> datetime:
|
||||
"""
|
||||
Get threshold for fetching access attempts from the database.
|
||||
"""
|
||||
|
||||
cool_off = get_cool_off(request)
|
||||
cool_off = get_cool_off()
|
||||
if cool_off is None:
|
||||
raise TypeError(
|
||||
"Cool off threshold can not be calculated with settings.AXES_COOLOFF_TIME set to None"
|
||||
)
|
||||
|
||||
attempt_time = request.axes_attempt_time # type: ignore[union-attr]
|
||||
if attempt_time is None:
|
||||
return now() - cool_off
|
||||
return attempt_time - cool_off
|
||||
|
||||
|
||||
def filter_user_attempts(
|
||||
request: HttpRequest, credentials: Optional[dict] = None
|
||||
) -> List[QuerySet]:
|
||||
"""
|
||||
Return a list querysets of AccessAttempts that match the given request and credentials.
|
||||
"""
|
||||
|
||||
username = get_client_username(request, credentials)
|
||||
|
||||
filter_kwargs_list = get_client_parameters(
|
||||
username, request.axes_ip_address, request.axes_user_agent, request, credentials
|
||||
)
|
||||
attempts_list = [
|
||||
AccessAttempt.objects.filter(**filter_kwargs)
|
||||
for filter_kwargs in filter_kwargs_list
|
||||
]
|
||||
return attempts_list
|
||||
|
||||
|
||||
def get_user_attempts(
|
||||
request: HttpRequest, credentials: Optional[dict] = None
|
||||
) -> List[QuerySet]:
|
||||
"""
|
||||
Get list of querysets with valid user attempts that match the given request and credentials.
|
||||
"""
|
||||
|
||||
attempts_list = filter_user_attempts(request, credentials)
|
||||
|
||||
if settings.AXES_COOLOFF_TIME is None:
|
||||
log.debug(
|
||||
"AXES: Getting all access attempts from database because no AXES_COOLOFF_TIME is configured"
|
||||
)
|
||||
return attempts_list
|
||||
|
||||
threshold = get_cool_off_threshold(request.axes_attempt_time)
|
||||
log.debug("AXES: Getting access attempts that are newer than %s", threshold)
|
||||
return [attempts.filter(attempt_time__gte=threshold) for attempts in attempts_list]
|
||||
|
||||
|
||||
def clean_expired_user_attempts(attempt_time: Optional[datetime] = None) -> int:
|
||||
"""
|
||||
Clean expired user attempts from the database.
|
||||
"""
|
||||
|
||||
if settings.AXES_COOLOFF_TIME is None:
|
||||
log.debug(
|
||||
"AXES: Skipping clean for expired access attempts because no AXES_COOLOFF_TIME is configured"
|
||||
)
|
||||
return 0
|
||||
|
||||
threshold = get_cool_off_threshold(attempt_time)
|
||||
count, _ = AccessAttempt.objects.filter(attempt_time__lt=threshold).delete()
|
||||
log.info(
|
||||
"AXES: Cleaned up %s expired access attempts from database that were older than %s",
|
||||
count,
|
||||
threshold,
|
||||
)
|
||||
return count
|
||||
|
||||
|
||||
def reset_user_attempts(
|
||||
request: HttpRequest, credentials: Optional[dict] = None
|
||||
) -> int:
|
||||
"""
|
||||
Reset all user attempts that match the given request and credentials.
|
||||
"""
|
||||
|
||||
attempts_list = filter_user_attempts(request, credentials)
|
||||
|
||||
count = 0
|
||||
for attempts in attempts_list:
|
||||
_count, _ = attempts.delete()
|
||||
count += _count
|
||||
log.info("AXES: Reset %s access attempts from database.", count)
|
||||
|
||||
return count
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from typing import Optional
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
from django.contrib.auth.backends import BaseBackend, ModelBackend
|
||||
from django.http import HttpRequest
|
||||
|
||||
from axes.exceptions import (
|
||||
|
|
@ -11,7 +11,7 @@ from axes.handlers.proxy import AxesProxyHandler
|
|||
from axes.helpers import get_credentials, get_lockout_message, toggleable
|
||||
|
||||
|
||||
class AxesStandaloneBackend:
|
||||
class AxesStandaloneBackend(BaseBackend):
|
||||
"""
|
||||
Authentication backend class that forbids login attempts for locked out users.
|
||||
|
||||
|
|
|
|||
|
|
@ -21,11 +21,6 @@ class Messages:
|
|||
)
|
||||
BACKEND_INVALID = "You do not have 'axes.backends.AxesStandaloneBackend' or a subclass in your settings.AUTHENTICATION_BACKENDS."
|
||||
SETTING_DEPRECATED = "You have a deprecated setting {deprecated_setting} configured in your project settings"
|
||||
CALLABLE_INVALID = "{callable_setting} is not a valid callable."
|
||||
LOCKOUT_PARAMETERS_INVALID = (
|
||||
"AXES_LOCKOUT_PARAMETERS does not contain 'ip_address'."
|
||||
" This configuration allows attackers to bypass rate limits by rotating User-Agents or Cookies."
|
||||
)
|
||||
|
||||
|
||||
class Hints:
|
||||
|
|
@ -33,8 +28,6 @@ class Hints:
|
|||
MIDDLEWARE_INVALID = None
|
||||
BACKEND_INVALID = "AxesModelBackend was renamed to AxesStandaloneBackend in django-axes version 5.0."
|
||||
SETTING_DEPRECATED = None
|
||||
CALLABLE_INVALID = None
|
||||
LOCKOUT_PARAMETERS_INVALID = "Add 'ip_address' to AXES_LOCKOUT_PARAMETERS."
|
||||
|
||||
|
||||
class Codes:
|
||||
|
|
@ -42,8 +35,6 @@ class Codes:
|
|||
MIDDLEWARE_INVALID = "axes.W002"
|
||||
BACKEND_INVALID = "axes.W003"
|
||||
SETTING_DEPRECATED = "axes.W004"
|
||||
CALLABLE_INVALID = "axes.W005"
|
||||
LOCKOUT_PARAMETERS_INVALID = "axes.W006"
|
||||
|
||||
|
||||
@register(Tags.security, Tags.caches, Tags.compatibility)
|
||||
|
|
@ -162,77 +153,3 @@ def axes_deprecation_check(app_configs, **kwargs): # pylint: disable=unused-arg
|
|||
pass
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
@register(Tags.security)
|
||||
def axes_lockout_params_check(app_configs, **kwargs): # pylint: disable=unused-argument
|
||||
warnings = []
|
||||
|
||||
lockout_params = getattr(settings, "AXES_LOCKOUT_PARAMETERS", None)
|
||||
|
||||
if isinstance(lockout_params, (list, tuple)):
|
||||
has_ip = False
|
||||
for param in lockout_params:
|
||||
if param == "ip_address":
|
||||
has_ip = True
|
||||
break
|
||||
if isinstance(param, (list, tuple)) and "ip_address" in param:
|
||||
has_ip = True
|
||||
break
|
||||
|
||||
if not has_ip:
|
||||
warnings.append(
|
||||
Warning(
|
||||
msg=Messages.LOCKOUT_PARAMETERS_INVALID,
|
||||
hint=Hints.LOCKOUT_PARAMETERS_INVALID,
|
||||
id=Codes.LOCKOUT_PARAMETERS_INVALID,
|
||||
)
|
||||
)
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
@register
|
||||
def axes_conf_check(app_configs, **kwargs): # pylint: disable=unused-argument
|
||||
warnings = []
|
||||
|
||||
callable_settings = [
|
||||
"AXES_CLIENT_IP_CALLABLE",
|
||||
"AXES_CLIENT_STR_CALLABLE",
|
||||
"AXES_LOCKOUT_CALLABLE",
|
||||
"AXES_USERNAME_CALLABLE",
|
||||
"AXES_WHITELIST_CALLABLE",
|
||||
"AXES_COOLOFF_TIME",
|
||||
"AXES_LOCKOUT_PARAMETERS",
|
||||
]
|
||||
|
||||
for callable_setting in callable_settings:
|
||||
value = getattr(settings, callable_setting, None)
|
||||
if not is_valid_callable(value):
|
||||
warnings.append(
|
||||
Warning(
|
||||
msg=Messages.CALLABLE_INVALID.format(
|
||||
callable_setting=callable_setting
|
||||
),
|
||||
hint=Hints.CALLABLE_INVALID,
|
||||
id=Codes.CALLABLE_INVALID,
|
||||
)
|
||||
)
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def is_valid_callable(value) -> bool:
|
||||
if value is None:
|
||||
return True
|
||||
|
||||
if callable(value):
|
||||
return True
|
||||
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
import_string(value)
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
|
|
|||
30
axes/conf.py
30
axes/conf.py
|
|
@ -1,21 +1,6 @@
|
|||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class JSONSerializableLazyObject(SimpleLazyObject):
|
||||
"""
|
||||
Celery/Kombu config inspection may JSON-encode Django settings.
|
||||
Provide a JSON-friendly representation for lazy values.
|
||||
|
||||
Fixes jazzband/django-axes#1391
|
||||
"""
|
||||
|
||||
def __json__(self):
|
||||
return str(self)
|
||||
|
||||
|
||||
# disable plugin when set to False
|
||||
settings.AXES_ENABLED = getattr(settings, "AXES_ENABLED", True)
|
||||
|
||||
|
|
@ -56,16 +41,9 @@ settings.AXES_ONLY_ADMIN_SITE = getattr(settings, "AXES_ONLY_ADMIN_SITE", False)
|
|||
# show Axes logs in admin
|
||||
settings.AXES_ENABLE_ADMIN = getattr(settings, "AXES_ENABLE_ADMIN", True)
|
||||
|
||||
|
||||
# use a specific username field to retrieve from login POST data
|
||||
def _get_username_field_default():
|
||||
return get_user_model().USERNAME_FIELD
|
||||
|
||||
|
||||
settings.AXES_USERNAME_FORM_FIELD = getattr(
|
||||
settings,
|
||||
"AXES_USERNAME_FORM_FIELD",
|
||||
JSONSerializableLazyObject(_get_username_field_default),
|
||||
settings, "AXES_USERNAME_FORM_FIELD", "username"
|
||||
)
|
||||
|
||||
# use a specific password field to retrieve from login POST data
|
||||
|
|
@ -108,10 +86,6 @@ settings.AXES_LOCKOUT_URL = getattr(settings, "AXES_LOCKOUT_URL", None)
|
|||
|
||||
settings.AXES_COOLOFF_TIME = getattr(settings, "AXES_COOLOFF_TIME", None)
|
||||
|
||||
settings.AXES_USE_ATTEMPT_EXPIRATION = getattr(
|
||||
settings, "AXES_USE_ATTEMPT_EXPIRATION", False
|
||||
)
|
||||
|
||||
settings.AXES_VERBOSE = getattr(settings, "AXES_VERBOSE", settings.AXES_ENABLED)
|
||||
|
||||
# whitelist and blacklist
|
||||
|
|
@ -150,7 +124,7 @@ settings.AXES_ALLOWED_CORS_ORIGINS = getattr(settings, "AXES_ALLOWED_CORS_ORIGIN
|
|||
settings.AXES_SENSITIVE_PARAMETERS = getattr(
|
||||
settings,
|
||||
"AXES_SENSITIVE_PARAMETERS",
|
||||
["username", "ip_address"],
|
||||
[],
|
||||
)
|
||||
|
||||
# set the callable for the readable string that can be used in
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
from warnings import warn
|
||||
|
||||
from django.urls import reverse
|
||||
from django.urls.exceptions import NoReverseMatch
|
||||
|
|
@ -82,7 +81,7 @@ class AxesBaseHandler: # pylint: disable=unused-argument
|
|||
and inspiration on some common checks and access restrictions before writing your own implementation.
|
||||
"""
|
||||
|
||||
if settings.AXES_ONLY_ADMIN_SITE and not self.is_admin_request(request):
|
||||
if self.is_admin_site(request):
|
||||
return True
|
||||
|
||||
if self.is_blacklisted(request, credentials):
|
||||
|
|
@ -135,41 +134,10 @@ class AxesBaseHandler: # pylint: disable=unused-argument
|
|||
|
||||
return False
|
||||
|
||||
def get_admin_url(self) -> Optional[str]:
|
||||
"""
|
||||
Returns admin url if exists, otherwise returns None
|
||||
"""
|
||||
try:
|
||||
return reverse("admin:index")
|
||||
except NoReverseMatch:
|
||||
return None
|
||||
|
||||
def is_admin_request(self, request) -> bool:
|
||||
"""
|
||||
Checks that request located under admin site
|
||||
"""
|
||||
if hasattr(request, "path"):
|
||||
admin_url = self.get_admin_url()
|
||||
return (
|
||||
admin_url is not None
|
||||
and re.match(f"^{admin_url}", request.path) is not None
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
def is_admin_site(self, request) -> bool:
|
||||
"""
|
||||
Checks if the request is NOT for admin site
|
||||
if `settings.AXES_ONLY_ADMIN_SITE` is True.
|
||||
Checks if the request is for admin site.
|
||||
"""
|
||||
warn(
|
||||
(
|
||||
"This method is deprecated and will be removed in future versions. "
|
||||
"If you looking for method that checks if `request.path` located under "
|
||||
"admin site, use `is_admin_request` instead."
|
||||
),
|
||||
DeprecationWarning,
|
||||
)
|
||||
if settings.AXES_ONLY_ADMIN_SITE and hasattr(request, "path"):
|
||||
try:
|
||||
admin_url = reverse("admin:index")
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
return
|
||||
|
||||
cache_keys = get_client_cache_keys(request, credentials)
|
||||
cache_timeout = get_cache_timeout(request)
|
||||
cache_timeout = get_cache_timeout()
|
||||
failures = []
|
||||
for cache_key in cache_keys:
|
||||
added = self.cache.add(key=cache_key, value=1, timeout=cache_timeout)
|
||||
|
|
|
|||
|
|
@ -1,32 +1,27 @@
|
|||
from logging import getLogger
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
|
||||
from django.db import router, transaction
|
||||
from django.db.models import F, Q, QuerySet, Sum, Value
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Sum, Value, Q
|
||||
from django.db.models.functions import Concat
|
||||
from django.http import HttpRequest
|
||||
from django.utils import timezone
|
||||
|
||||
from axes.attempts import get_cool_off_threshold
|
||||
from axes.attempts import (
|
||||
clean_expired_user_attempts,
|
||||
get_user_attempts,
|
||||
reset_user_attempts,
|
||||
)
|
||||
from axes.conf import settings
|
||||
from axes.handlers.base import AbstractAxesHandler, AxesBaseHandler
|
||||
from axes.handlers.base import AxesBaseHandler, AbstractAxesHandler
|
||||
from axes.helpers import (
|
||||
get_client_parameters,
|
||||
get_client_session_hash,
|
||||
get_client_str,
|
||||
get_client_username,
|
||||
get_credentials,
|
||||
get_failure_limit,
|
||||
get_lockout_parameters,
|
||||
get_query_str,
|
||||
get_attempt_expiration,
|
||||
)
|
||||
from axes.models import (
|
||||
AccessAttempt,
|
||||
AccessAttemptExpiration,
|
||||
AccessFailureLog,
|
||||
AccessLog,
|
||||
)
|
||||
from axes.models import AccessLog, AccessAttempt, AccessFailureLog
|
||||
from axes.signals import user_locked_out
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
|
@ -108,7 +103,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
return count
|
||||
|
||||
def get_failures(self, request, credentials: Optional[dict] = None) -> int:
|
||||
attempts_list = self.get_user_attempts(request, credentials)
|
||||
attempts_list = get_user_attempts(request, credentials)
|
||||
attempt_count = max(
|
||||
(
|
||||
attempts.aggregate(Sum("failures_since_start"))[
|
||||
|
|
@ -121,10 +116,10 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
return attempt_count
|
||||
|
||||
def user_login_failed(self, sender, credentials: dict, request=None, **kwargs):
|
||||
"""
|
||||
When user login fails, save AccessFailureLog record in database,
|
||||
"""When user login fails, save AccessFailureLog record in database,
|
||||
save AccessAttempt record in database, mark request with
|
||||
lockout attribute and emit lockout signal.
|
||||
|
||||
"""
|
||||
|
||||
log.info("AXES: User login failed, running database handler for failure.")
|
||||
|
|
@ -136,7 +131,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
return
|
||||
|
||||
# 1. database query: Clean up expired user attempts from the database before logging new attempts
|
||||
self.clean_expired_user_attempts(request, credentials)
|
||||
clean_expired_user_attempts(request.axes_attempt_time)
|
||||
|
||||
username = get_client_username(request, credentials)
|
||||
client_str = get_client_str(
|
||||
|
|
@ -176,7 +171,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
"AXES: Username is None and username is the only one lockout parameter, new record will NOT be created."
|
||||
)
|
||||
else:
|
||||
with transaction.atomic(using=router.db_for_write(AccessAttempt)):
|
||||
with transaction.atomic():
|
||||
(
|
||||
attempt,
|
||||
created,
|
||||
|
|
@ -225,23 +220,6 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
client_str,
|
||||
)
|
||||
|
||||
if settings.AXES_USE_ATTEMPT_EXPIRATION:
|
||||
if not hasattr(attempt, "expiration") or attempt.expiration is None:
|
||||
log.debug(
|
||||
"AXES: Creating new AccessAttemptExpiration for %s",
|
||||
client_str,
|
||||
)
|
||||
attempt.expiration = AccessAttemptExpiration.objects.create(
|
||||
access_attempt=attempt,
|
||||
expires_at=get_attempt_expiration(request),
|
||||
)
|
||||
else:
|
||||
attempt.expiration.expires_at = max(
|
||||
get_attempt_expiration(request),
|
||||
attempt.expiration.expires_at,
|
||||
)
|
||||
attempt.expiration.save()
|
||||
|
||||
# 3. or 4. database query: Calculate the current maximum failure number from the existing attempts
|
||||
failures_since_start = self.get_failures(request, credentials)
|
||||
request.axes_failures_since_start = failures_since_start
|
||||
|
|
@ -265,7 +243,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
|
||||
# 5. database entry: Log for ever the attempt in the AccessFailureLog
|
||||
if settings.AXES_ENABLE_ACCESS_FAILURE_LOG:
|
||||
with transaction.atomic(using=router.db_for_write(AccessFailureLog)):
|
||||
with transaction.atomic():
|
||||
AccessFailureLog.objects.create(
|
||||
username=username,
|
||||
ip_address=request.axes_ip_address,
|
||||
|
|
@ -282,6 +260,9 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
When user logs in, update the AccessLog related to the user.
|
||||
"""
|
||||
|
||||
# 1. database query: Clean up expired user attempts from the database
|
||||
clean_expired_user_attempts(request.axes_attempt_time)
|
||||
|
||||
username = user.get_username()
|
||||
credentials = get_credentials(username)
|
||||
client_str = get_client_str(
|
||||
|
|
@ -294,9 +275,6 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
|
||||
log.info("AXES: Successful login by %s.", client_str)
|
||||
|
||||
# 1. database query: Clean up expired user attempts from the database
|
||||
self.clean_expired_user_attempts(request, credentials)
|
||||
|
||||
if not settings.AXES_DISABLE_ACCESS_LOG:
|
||||
# 2. database query: Insert new access logs with login time
|
||||
AccessLog.objects.create(
|
||||
|
|
@ -306,14 +284,11 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
http_accept=request.axes_http_accept,
|
||||
path_info=request.axes_path_info,
|
||||
attempt_time=request.axes_attempt_time,
|
||||
# evaluate session hash here to ensure having the correct
|
||||
# value which is stored on the backend
|
||||
session_hash=get_client_session_hash(request),
|
||||
)
|
||||
|
||||
if settings.AXES_RESET_ON_SUCCESS:
|
||||
# 3. database query: Reset failed attempts for the logging in user
|
||||
count = self.reset_user_attempts(request, credentials)
|
||||
count = reset_user_attempts(request, credentials)
|
||||
log.info(
|
||||
"AXES: Deleted %d failed login attempts by %s from database.",
|
||||
count,
|
||||
|
|
@ -325,8 +300,10 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
When user logs out, update the AccessLog related to the user.
|
||||
"""
|
||||
|
||||
# 1. database query: Clean up expired user attempts from the database
|
||||
clean_expired_user_attempts(request.axes_attempt_time)
|
||||
|
||||
username = user.get_username() if user else None
|
||||
credentials = get_credentials(username) if username else None
|
||||
client_str = get_client_str(
|
||||
username,
|
||||
request.axes_ip_address,
|
||||
|
|
@ -335,117 +312,14 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
request,
|
||||
)
|
||||
|
||||
# 1. database query: Clean up expired user attempts from the database
|
||||
self.clean_expired_user_attempts(request, credentials)
|
||||
|
||||
log.info("AXES: Successful logout by %s.", client_str)
|
||||
|
||||
if username and not settings.AXES_DISABLE_ACCESS_LOG:
|
||||
# 2. database query: Update existing attempt logs with logout time
|
||||
AccessLog.objects.filter(
|
||||
username=username,
|
||||
logout_time__isnull=True,
|
||||
# update only access log for given session
|
||||
session_hash=get_client_session_hash(request),
|
||||
username=username, logout_time__isnull=True
|
||||
).update(logout_time=request.axes_attempt_time)
|
||||
|
||||
def filter_user_attempts(
|
||||
self, request: HttpRequest, credentials: Optional[dict] = None
|
||||
) -> List[QuerySet]:
|
||||
"""
|
||||
Return a list querysets of AccessAttempts that match the given request and credentials.
|
||||
"""
|
||||
|
||||
username = get_client_username(request, credentials)
|
||||
|
||||
filter_kwargs_list = get_client_parameters(
|
||||
username,
|
||||
request.axes_ip_address,
|
||||
request.axes_user_agent,
|
||||
request,
|
||||
credentials,
|
||||
)
|
||||
attempts_list = [
|
||||
AccessAttempt.objects.filter(**filter_kwargs)
|
||||
for filter_kwargs in filter_kwargs_list
|
||||
]
|
||||
return attempts_list
|
||||
|
||||
def get_user_attempts(
|
||||
self, request: HttpRequest, credentials: Optional[dict] = None # noqa
|
||||
) -> List[QuerySet]:
|
||||
"""
|
||||
Get list of querysets with valid user attempts that match the given request and credentials.
|
||||
"""
|
||||
|
||||
attempts_list = self.filter_user_attempts(request, credentials)
|
||||
|
||||
if settings.AXES_COOLOFF_TIME is None:
|
||||
log.debug(
|
||||
"AXES: Getting all access attempts from database because no AXES_COOLOFF_TIME is configured"
|
||||
)
|
||||
return attempts_list
|
||||
|
||||
threshold = get_cool_off_threshold(request)
|
||||
log.debug("AXES: Getting access attempts that are newer than %s", threshold)
|
||||
return [
|
||||
attempts.filter(attempt_time__gte=threshold) for attempts in attempts_list
|
||||
]
|
||||
|
||||
def clean_expired_user_attempts(
|
||||
self,
|
||||
request: Optional[HttpRequest] = None,
|
||||
credentials: Optional[dict] = None, # noqa
|
||||
) -> int:
|
||||
"""
|
||||
Clean expired user attempts from the database.
|
||||
"""
|
||||
|
||||
if settings.AXES_COOLOFF_TIME is None:
|
||||
log.debug(
|
||||
"AXES: Skipping clean for expired access attempts because no AXES_COOLOFF_TIME is configured"
|
||||
)
|
||||
return 0
|
||||
|
||||
if settings.AXES_USE_ATTEMPT_EXPIRATION:
|
||||
threshold = timezone.now()
|
||||
count, _ = AccessAttempt.objects.filter(
|
||||
expiration__expires_at__lte=threshold
|
||||
).delete()
|
||||
log.info(
|
||||
"AXES: Cleaned up %s expired access attempts from database that expiry were older than %s",
|
||||
count,
|
||||
threshold,
|
||||
)
|
||||
else:
|
||||
threshold = get_cool_off_threshold(request)
|
||||
count, _ = AccessAttempt.objects.filter(
|
||||
attempt_time__lte=threshold
|
||||
).delete()
|
||||
log.info(
|
||||
"AXES: Cleaned up %s expired access attempts from database that were older than %s",
|
||||
count,
|
||||
threshold,
|
||||
)
|
||||
return count
|
||||
|
||||
def reset_user_attempts(
|
||||
self, request: HttpRequest, credentials: Optional[dict] = None
|
||||
) -> int:
|
||||
"""
|
||||
Reset all user attempts that match the given request and credentials.
|
||||
"""
|
||||
|
||||
attempts_list = self.filter_user_attempts(request, credentials)
|
||||
|
||||
count = 0
|
||||
for attempts in attempts_list:
|
||||
_count, _ = attempts.delete()
|
||||
count += _count
|
||||
log.info("AXES: Reset %s access attempts from database.", count)
|
||||
|
||||
return count
|
||||
|
||||
def post_save_access_attempt(self, instance, **kwargs):
|
||||
"""
|
||||
Handles the ``axes.models.AccessAttempt`` object post save signal.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from datetime import timedelta, datetime
|
||||
from datetime import timedelta
|
||||
from hashlib import sha256
|
||||
from logging import getLogger
|
||||
from string import Template
|
||||
|
|
@ -8,7 +8,6 @@ from urllib.parse import urlencode
|
|||
from django.core.cache import BaseCache, caches
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse, QueryDict
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils.encoding import force_bytes
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from axes.conf import settings
|
||||
|
|
@ -32,33 +31,32 @@ def get_cache() -> BaseCache:
|
|||
return caches[getattr(settings, "AXES_CACHE", "default")]
|
||||
|
||||
|
||||
def get_cache_timeout(request: Optional[HttpRequest] = None) -> Optional[int]:
|
||||
def get_cache_timeout() -> Optional[int]:
|
||||
"""
|
||||
Return the cache timeout interpreted from settings.AXES_COOLOFF_TIME.
|
||||
|
||||
The cache timeout can be either None if not configured or integer of seconds if configured.
|
||||
|
||||
Notice that the settings.AXES_COOLOFF_TIME can be None, timedelta, float, integer, callable, or str path,
|
||||
Notice that the settings.AXES_COOLOFF_TIME can be None, timedelta, integer, callable, or str path,
|
||||
and this function offers a unified _integer or None_ representation of that configuration
|
||||
for use with the Django cache backends.
|
||||
"""
|
||||
|
||||
cool_off = get_cool_off(request)
|
||||
cool_off = get_cool_off()
|
||||
if cool_off is None:
|
||||
return None
|
||||
return int(cool_off.total_seconds())
|
||||
|
||||
|
||||
def get_cool_off(request: Optional[HttpRequest] = None) -> Optional[timedelta]:
|
||||
def get_cool_off() -> Optional[timedelta]:
|
||||
"""
|
||||
Return the login cool off time interpreted from settings.AXES_COOLOFF_TIME.
|
||||
|
||||
The return value is either None or timedelta.
|
||||
|
||||
Notice that the settings.AXES_COOLOFF_TIME is either None, timedelta, integer/float of hours,
|
||||
a path to a callable or a callable taking 1 argument (the request). This function
|
||||
offers a unified _timedelta or None_ representation of that configuration for use with the
|
||||
Axes internal implementations.
|
||||
Notice that the settings.AXES_COOLOFF_TIME is either None, timedelta, or integer of hours,
|
||||
and this function offers a unified _timedelta or None_ representation of that configuration
|
||||
for use with the Axes internal implementations.
|
||||
|
||||
:exception TypeError: if settings.AXES_COOLOFF_TIME is of wrong type.
|
||||
"""
|
||||
|
|
@ -70,10 +68,9 @@ def get_cool_off(request: Optional[HttpRequest] = None) -> Optional[timedelta]:
|
|||
if isinstance(cool_off, float):
|
||||
return timedelta(minutes=cool_off * 60)
|
||||
if isinstance(cool_off, str):
|
||||
cool_off_func = import_string(cool_off)
|
||||
return cool_off_func(request)
|
||||
return import_string(cool_off)()
|
||||
if callable(cool_off):
|
||||
return cool_off(request) # pylint: disable=not-callable
|
||||
return cool_off() # pylint: disable=not-callable
|
||||
|
||||
return cool_off
|
||||
|
||||
|
|
@ -101,23 +98,6 @@ def get_cool_off_iso8601(delta: timedelta) -> str:
|
|||
return f"P{days_str}"
|
||||
|
||||
|
||||
def get_attempt_expiration(request: Optional[HttpRequest] = None) -> datetime:
|
||||
"""
|
||||
Get threshold for fetching access attempts from the database.
|
||||
"""
|
||||
|
||||
cool_off = get_cool_off(request)
|
||||
if cool_off is None:
|
||||
raise TypeError(
|
||||
"Cool off threshold can not be calculated with settings.AXES_COOLOFF_TIME set to None"
|
||||
)
|
||||
|
||||
attempt_time = request.axes_attempt_time # type: ignore[union-attr]
|
||||
if attempt_time is None:
|
||||
return datetime.now() + cool_off
|
||||
return attempt_time + cool_off
|
||||
|
||||
|
||||
def get_credentials(username: Optional[str] = None, **kwargs) -> dict:
|
||||
"""
|
||||
Calculate credentials for Axes to use internally from given username and kwargs.
|
||||
|
|
@ -164,7 +144,7 @@ def get_client_username(
|
|||
log.debug(
|
||||
"Using parameter credentials to get username with key settings.AXES_USERNAME_FORM_FIELD"
|
||||
)
|
||||
return credentials.get(settings.AXES_USERNAME_FORM_FIELD, None) # type: ignore[return-value]
|
||||
return credentials.get(settings.AXES_USERNAME_FORM_FIELD, None)
|
||||
|
||||
log.debug(
|
||||
"Using parameter request.POST to get username with key settings.AXES_USERNAME_FORM_FIELD"
|
||||
|
|
@ -462,27 +442,15 @@ def get_lockout_message() -> str:
|
|||
|
||||
|
||||
def get_lockout_response(
|
||||
request: HttpRequest,
|
||||
original_response: Optional[HttpResponse] = None,
|
||||
credentials: Optional[dict] = None,
|
||||
request: HttpRequest, credentials: Optional[dict] = None
|
||||
) -> HttpResponse:
|
||||
if settings.AXES_LOCKOUT_CALLABLE:
|
||||
if callable(settings.AXES_LOCKOUT_CALLABLE):
|
||||
# Try calling with 3 args, fallback to 2 for backward compatibility
|
||||
try:
|
||||
return settings.AXES_LOCKOUT_CALLABLE(
|
||||
request, original_response, credentials
|
||||
)
|
||||
except TypeError:
|
||||
# Fallback: old signature without original_response
|
||||
return settings.AXES_LOCKOUT_CALLABLE(request, credentials)
|
||||
return settings.AXES_LOCKOUT_CALLABLE( # pylint: disable=not-callable
|
||||
request, credentials
|
||||
)
|
||||
if isinstance(settings.AXES_LOCKOUT_CALLABLE, str):
|
||||
callable_obj = import_string(settings.AXES_LOCKOUT_CALLABLE)
|
||||
# Try calling with 3 args, fallback to 2 for backward compatibility
|
||||
try:
|
||||
return callable_obj(request, original_response, credentials)
|
||||
except TypeError:
|
||||
return callable_obj(request, credentials)
|
||||
return import_string(settings.AXES_LOCKOUT_CALLABLE)(request, credentials)
|
||||
raise TypeError(
|
||||
"settings.AXES_LOCKOUT_CALLABLE needs to be a string, callable, or None."
|
||||
)
|
||||
|
|
@ -493,7 +461,7 @@ def get_lockout_response(
|
|||
"username": get_client_username(request, credentials) or "",
|
||||
}
|
||||
|
||||
cool_off = get_cool_off(request)
|
||||
cool_off = get_cool_off()
|
||||
if cool_off:
|
||||
context.update(
|
||||
{
|
||||
|
|
@ -506,13 +474,13 @@ def get_lockout_response(
|
|||
|
||||
if request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest":
|
||||
json_response = JsonResponse(context, status=status)
|
||||
json_response["Access-Control-Allow-Origin"] = (
|
||||
settings.AXES_ALLOWED_CORS_ORIGINS
|
||||
)
|
||||
json_response[
|
||||
"Access-Control-Allow-Origin"
|
||||
] = settings.AXES_ALLOWED_CORS_ORIGINS
|
||||
json_response["Access-Control-Allow-Methods"] = "POST, OPTIONS"
|
||||
json_response["Access-Control-Allow-Headers"] = (
|
||||
"Origin, Content-Type, Accept, Authorization, x-requested-with"
|
||||
)
|
||||
json_response[
|
||||
"Access-Control-Allow-Headers"
|
||||
] = "Origin, Content-Type, Accept, Authorization, x-requested-with"
|
||||
return json_response
|
||||
|
||||
if settings.AXES_LOCKOUT_TEMPLATE:
|
||||
|
|
@ -646,24 +614,3 @@ def toggleable(func) -> Callable:
|
|||
return func(*args, **kwargs)
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
def get_client_session_hash(request: HttpRequest) -> str:
|
||||
"""
|
||||
Get client session and returns the SHA256 hash of session key, forcing session creation if required.
|
||||
|
||||
If no session is available on request returns an empty string.
|
||||
"""
|
||||
try:
|
||||
session = request.session
|
||||
except AttributeError:
|
||||
# when no session is available just return an empty string
|
||||
return ""
|
||||
|
||||
# ensure that a session key exists at this point
|
||||
# because session middleware usually creates the session key at the end
|
||||
# of request cycle
|
||||
if session.session_key is None:
|
||||
session.create()
|
||||
|
||||
return sha256(force_bytes(session.session_key)).hexdigest()
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,109 +0,0 @@
|
|||
# ترجمه فارسی برای django-axes
|
||||
# Copyright (C) 2025 jazzband
|
||||
# This file is distributed under the same license as the django-axes package.
|
||||
# AmirAli Bahramjerdi <amiralibahramjerdi@gmail.com>, 2025.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django-axes\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-05-16 23:28+0330\n"
|
||||
"PO-Revision-Date: 2025-05-16 23:30+0330\n"
|
||||
"Last-Translator: AmirAli Bahramjerdi <amiralibahramjerdi@gmail.com>"
|
||||
"Language-Team: فارسی <fa@li.org>\n"
|
||||
"Language: fa\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
|
||||
#: admin.py:27
|
||||
msgid "Form Data"
|
||||
msgstr "دادههای فرم"
|
||||
|
||||
#: admin.py:28 admin.py:65 admin.py:100
|
||||
msgid "Meta Data"
|
||||
msgstr "فراداده"
|
||||
|
||||
#: conf.py:109
|
||||
msgid "Account locked: too many login attempts. Please try again later."
|
||||
msgstr "حساب کاربری قفل شد: تلاشهای زیادی برای ورود انجام شده است. لطفاً بعداً دوباره امتحان کنید."
|
||||
|
||||
#: conf.py:117
|
||||
msgid ""
|
||||
"Account locked: too many login attempts. Contact an admin to unlock your "
|
||||
"account."
|
||||
msgstr "حساب کاربری قفل شد: تلاشهای زیادی برای ورود انجام شده است. برای باز کردن حساب با مدیر تماس بگیرید."
|
||||
|
||||
#: models.py:6
|
||||
msgid "User Agent"
|
||||
msgstr "عامل کاربر (User Agent)"
|
||||
|
||||
#: models.py:8
|
||||
msgid "IP Address"
|
||||
msgstr "آدرس IP"
|
||||
|
||||
#: models.py:10
|
||||
msgid "Username"
|
||||
msgstr "نام کاربری"
|
||||
|
||||
#: models.py:12
|
||||
msgid "HTTP Accept"
|
||||
msgstr "پذیرش HTTP"
|
||||
|
||||
#: models.py:14
|
||||
msgid "Path"
|
||||
msgstr "مسیر"
|
||||
|
||||
#: models.py:16
|
||||
msgid "Attempt Time"
|
||||
msgstr "زمان تلاش"
|
||||
|
||||
#: models.py:26
|
||||
msgid "Access lock out"
|
||||
msgstr "قفل دسترسی"
|
||||
|
||||
#: models.py:34
|
||||
msgid "access failure"
|
||||
msgstr "شکست در دسترسی"
|
||||
|
||||
#: models.py:35
|
||||
msgid "access failures"
|
||||
msgstr "شکستهای دسترسی"
|
||||
|
||||
#: models.py:39
|
||||
msgid "GET Data"
|
||||
msgstr "دادههای GET"
|
||||
|
||||
#: models.py:41
|
||||
msgid "POST Data"
|
||||
msgstr "دادههای POST"
|
||||
|
||||
#: models.py:43
|
||||
msgid "Failed Logins"
|
||||
msgstr "ورودهای ناموفق"
|
||||
|
||||
#: models.py:49
|
||||
msgid "access attempt"
|
||||
msgstr "تلاش برای دسترسی"
|
||||
|
||||
#: models.py:50
|
||||
msgid "access attempts"
|
||||
msgstr "تلاشهای دسترسی"
|
||||
|
||||
#: models.py:55
|
||||
msgid "Logout Time"
|
||||
msgstr "زمان خروج"
|
||||
|
||||
#: models.py:56
|
||||
msgid "Session key hash (sha256)"
|
||||
msgstr "هش کلید نشست (sha256)"
|
||||
|
||||
#: models.py:62
|
||||
msgid "access log"
|
||||
msgstr "گزارش دسترسی"
|
||||
|
||||
#: models.py:63
|
||||
msgid "access logs"
|
||||
msgstr "گزارشهای دسترسی"
|
||||
Binary file not shown.
|
|
@ -1,109 +0,0 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-11-06 05:21-0600\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"Language: \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 "Données de formulaire"
|
||||
|
||||
#: admin.py:28 admin.py:65 admin.py:100
|
||||
msgid "Meta Data"
|
||||
msgstr "Métadonnées"
|
||||
|
||||
#: conf.py:108
|
||||
msgid "Account locked: too many login attempts. Please try again later."
|
||||
msgstr ""
|
||||
"Compte verrouillé: trop de tentatives de connexion. Veuillez réessayer plus "
|
||||
"tard."
|
||||
|
||||
#: conf.py:116
|
||||
msgid ""
|
||||
"Account locked: too many login attempts. Contact an admin to unlock your "
|
||||
"account."
|
||||
msgstr ""
|
||||
"Compte verrouillé: trop de tentatives de connexion. Contactez un "
|
||||
"administrateur pour déverrouiller votre compte."
|
||||
|
||||
#: models.py:6
|
||||
msgid "User Agent"
|
||||
msgstr "User Agent"
|
||||
|
||||
#: models.py:8
|
||||
msgid "IP Address"
|
||||
msgstr "Adresse IP"
|
||||
|
||||
#: models.py:10
|
||||
msgid "Username"
|
||||
msgstr "Nom d'utilisateur"
|
||||
|
||||
#: models.py:12
|
||||
msgid "HTTP Accept"
|
||||
msgstr "HTTP Accept"
|
||||
|
||||
#: models.py:14
|
||||
msgid "Path"
|
||||
msgstr "Chemin"
|
||||
|
||||
#: models.py:16
|
||||
msgid "Attempt Time"
|
||||
msgstr "Date de la tentative"
|
||||
|
||||
#: models.py:26
|
||||
msgid "Access lock out"
|
||||
msgstr "Verrouillage de l'accès"
|
||||
|
||||
#: models.py:34
|
||||
msgid "access failure"
|
||||
msgstr "échec de connexion"
|
||||
|
||||
#: models.py:35
|
||||
msgid "access failures"
|
||||
msgstr "échecs de connexion"
|
||||
|
||||
#: models.py:39
|
||||
msgid "GET Data"
|
||||
msgstr "Données GET"
|
||||
|
||||
#: models.py:41
|
||||
msgid "POST Data"
|
||||
msgstr "Données POST"
|
||||
|
||||
#: models.py:43
|
||||
msgid "Failed Logins"
|
||||
msgstr "Nombre d'échecs"
|
||||
|
||||
#: models.py:49
|
||||
msgid "access attempt"
|
||||
msgstr "tentative de connexion"
|
||||
|
||||
#: models.py:50
|
||||
msgid "access attempts"
|
||||
msgstr "tentatives de connexion"
|
||||
|
||||
#: models.py:55
|
||||
msgid "Logout Time"
|
||||
msgstr "Date de la déconnexion"
|
||||
|
||||
#: models.py:61
|
||||
msgid "access log"
|
||||
msgstr "connexion"
|
||||
|
||||
#: models.py:62
|
||||
msgid "access logs"
|
||||
msgstr "connexions"
|
||||
Binary file not shown.
|
|
@ -1,105 +0,0 @@
|
|||
# Indonesian translation for django-axes.
|
||||
# Copyright (C) 2023
|
||||
# This file is distributed under the same license as the django-axes package.
|
||||
# Kira <kiraware@github.com>, 2023.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django-axes 6.0.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-06-30 09:21+0800\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: Kira <kiraware@github.com>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"Language: id\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
#: .\axes\admin.py:27
|
||||
msgid "Form Data"
|
||||
msgstr "Data Formulir"
|
||||
|
||||
#: .\axes\admin.py:28 .\axes\admin.py:65 .\axes\admin.py:100
|
||||
msgid "Meta Data"
|
||||
msgstr "Meta Data"
|
||||
|
||||
#: .\axes\conf.py:108
|
||||
msgid "Account locked: too many login attempts. Please try again later."
|
||||
msgstr "Akun terkunci: terlalu banyak percobaan login. Silakan coba lagi nanti."
|
||||
|
||||
#: .\axes\conf.py:116
|
||||
msgid ""
|
||||
"Account locked: too many login attempts. Contact an admin to unlock your "
|
||||
"account."
|
||||
msgstr "Akun terkunci: terlalu banyak percobaan login. Hubungi admin untuk"
|
||||
" membuka kunci akun"
|
||||
|
||||
#: .\axes\models.py:6
|
||||
msgid "User Agent"
|
||||
msgstr "User Agent"
|
||||
|
||||
#: .\axes\models.py:8
|
||||
msgid "IP Address"
|
||||
msgstr "Alamat IP"
|
||||
|
||||
#: .\axes\models.py:10
|
||||
msgid "Username"
|
||||
msgstr "Nama Pengguna"
|
||||
|
||||
#: .\axes\models.py:12
|
||||
msgid "HTTP Accept"
|
||||
msgstr "HTTP Accept"
|
||||
|
||||
#: .\axes\models.py:14
|
||||
msgid "Path"
|
||||
msgstr "Path"
|
||||
|
||||
#: .\axes\models.py:16
|
||||
msgid "Attempt Time"
|
||||
msgstr "Waktu Percobaan"
|
||||
|
||||
#: .\axes\models.py:26
|
||||
msgid "Access lock out"
|
||||
msgstr "Akses terkunci"
|
||||
|
||||
#: .\axes\models.py:34
|
||||
msgid "access failure"
|
||||
msgstr "kegagalan akses"
|
||||
|
||||
#: .\axes\models.py:35
|
||||
msgid "access failures"
|
||||
msgstr "kegagalan akses"
|
||||
|
||||
#: .\axes\models.py:39
|
||||
msgid "GET Data"
|
||||
msgstr "Data GET"
|
||||
|
||||
#: .\axes\models.py:41
|
||||
msgid "POST Data"
|
||||
msgstr "Data POST"
|
||||
|
||||
#: .\axes\models.py:43
|
||||
msgid "Failed Logins"
|
||||
msgstr "Login Gagal"
|
||||
|
||||
#: .\axes\models.py:49
|
||||
msgid "access attempt"
|
||||
msgstr "upaya akses"
|
||||
|
||||
#: .\axes\models.py:50
|
||||
msgid "access attempts"
|
||||
msgstr "upaya akses"
|
||||
|
||||
#: .\axes\models.py:55
|
||||
msgid "Logout Time"
|
||||
msgstr "Waktu Logout"
|
||||
|
||||
#: .\axes\models.py:61
|
||||
msgid "access log"
|
||||
msgstr "log akses"
|
||||
|
||||
#: .\axes\models.py:62
|
||||
msgid "access logs"
|
||||
msgstr "log akses"
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
from django.core.management.base import BaseCommand
|
||||
|
||||
from axes.utils import reset
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Reset all access attempts and lockouts for a given IP address and username"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("ip", type=str)
|
||||
parser.add_argument("username", type=str)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
count = reset(ip=options["ip"], username=options["username"])
|
||||
|
||||
if count:
|
||||
self.stdout.write(f"{count} attempts removed.")
|
||||
else:
|
||||
self.stdout.write("No attempts found.")
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
from typing import Callable
|
||||
|
||||
from asgiref.sync import iscoroutinefunction, markcoroutinefunction, sync_to_async
|
||||
from django.conf import settings
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
|
||||
|
|
@ -31,37 +30,15 @@ class AxesMiddleware:
|
|||
- ``AXES_PERMALOCK_MESSAGE``.
|
||||
"""
|
||||
|
||||
async_capable = True
|
||||
sync_capable = True
|
||||
|
||||
def __init__(self, get_response: Callable) -> None:
|
||||
self.get_response = get_response
|
||||
if iscoroutinefunction(self.get_response):
|
||||
markcoroutinefunction(self)
|
||||
|
||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||
# Exit out to async mode, if needed
|
||||
if iscoroutinefunction(self):
|
||||
return self.__acall__(request)
|
||||
|
||||
response = self.get_response(request)
|
||||
if settings.AXES_ENABLED:
|
||||
if getattr(request, "axes_locked_out", None):
|
||||
credentials = getattr(request, "axes_credentials", None)
|
||||
response = get_lockout_response(request, response, credentials) # type: ignore
|
||||
|
||||
return response
|
||||
|
||||
async def __acall__(self, request: HttpRequest) -> HttpResponse:
|
||||
response = await self.get_response(request)
|
||||
|
||||
if settings.AXES_ENABLED:
|
||||
if getattr(request, "axes_locked_out", None):
|
||||
credentials = getattr(request, "axes_credentials", None)
|
||||
response = await sync_to_async(
|
||||
get_lockout_response, thread_sensitive=True
|
||||
)(
|
||||
request, credentials
|
||||
) # type: ignore
|
||||
response = get_lockout_response(request, credentials) # type: ignore
|
||||
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -1,16 +1,12 @@
|
|||
# Generated by Django 3.2.7 on 2021-09-13 15:16
|
||||
|
||||
from django.db import migrations, router
|
||||
from django.db import migrations
|
||||
from django.db.models import Count
|
||||
|
||||
|
||||
def deduplicate_attempts(apps, schema_editor):
|
||||
AccessAttempt = apps.get_model("axes", "AccessAttempt")
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
if db_alias != router.db_for_write(AccessAttempt):
|
||||
return
|
||||
|
||||
duplicated_attempts = (
|
||||
AccessAttempt.objects.using(db_alias)
|
||||
.values("username", "user_agent", "ip_address")
|
||||
|
|
@ -35,9 +31,7 @@ class Migration(migrations.Migration):
|
|||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
deduplicate_attempts, reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
migrations.RunPython(deduplicate_attempts),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="accessattempt",
|
||||
unique_together={("username", "ip_address", "user_agent")},
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
# Generated by Django 4.2.2 on 2024-04-30 07:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("axes", "0008_accessfailurelog"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="accesslog",
|
||||
name="session_hash",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default="",
|
||||
max_length=64,
|
||||
verbose_name="Session key hash (sha256)",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
# Generated by Django 5.2.1 on 2025-06-10 20:21
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("axes", "0009_add_session_hash"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AccessAttemptExpiration",
|
||||
fields=[
|
||||
(
|
||||
"access_attempt",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
primary_key=True,
|
||||
related_name="expiration",
|
||||
serialize=False,
|
||||
to="axes.accessattempt",
|
||||
verbose_name="Access Attempt",
|
||||
),
|
||||
),
|
||||
(
|
||||
"expires_at",
|
||||
models.DateTimeField(
|
||||
help_text="The time when access attempt expires and is no longer valid.",
|
||||
verbose_name="Expires At",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "access attempt expiration",
|
||||
"verbose_name_plural": "access attempt expirations",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -51,29 +51,8 @@ class AccessAttempt(AccessBase):
|
|||
unique_together = [["username", "ip_address", "user_agent"]]
|
||||
|
||||
|
||||
class AccessAttemptExpiration(models.Model):
|
||||
access_attempt = models.OneToOneField(
|
||||
AccessAttempt,
|
||||
primary_key=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="expiration",
|
||||
verbose_name=_("Access Attempt"),
|
||||
)
|
||||
expires_at = models.DateTimeField(
|
||||
_("Expires At"),
|
||||
help_text=_("The time when access attempt expires and is no longer valid."),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("access attempt expiration")
|
||||
verbose_name_plural = _("access attempt expirations")
|
||||
|
||||
|
||||
class AccessLog(AccessBase):
|
||||
logout_time = models.DateTimeField(_("Logout Time"), null=True, blank=True)
|
||||
session_hash = models.CharField(
|
||||
_("Session key hash (sha256)"), default="", blank=True, max_length=64
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"Access Log for {self.username} @ {self.attempt_time}"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Requirements
|
||||
============
|
||||
|
||||
Axes requires a supported Django version and runs on Python versions 3.9 and above.
|
||||
Axes requires a supported Django version and runs on Python versions 3.7 and above.
|
||||
|
||||
Refer to the project source code repository in
|
||||
`GitHub <https://github.com/jazzband/django-axes/>`_ and see the
|
||||
|
|
|
|||
|
|
@ -34,10 +34,10 @@ After installing the package, the project settings need to be configured.
|
|||
'django.contrib.auth.backends.ModelBackend',
|
||||
]
|
||||
|
||||
For backwards compatibility, ``AxesBackend`` can be used in place of ``AxesStandaloneBackend``.
|
||||
The only difference is that ``AxesBackend`` also provides the permissions-checking functionality
|
||||
of Django's ``ModelBackend`` behind the scenes. We recommend using ``AxesStandaloneBackend``
|
||||
if you have any custom logic to override Django's standard permissions checks.
|
||||
For backwards compatibility, ``AxesBackend`` can be used in place of ``AxesStandaloneBackend``.
|
||||
The only difference is that ``AxesBackend`` also provides the permissions-checking functionality
|
||||
of Django's ``ModelBackend`` behind the scenes. We recommend using ``AxesStandaloneBackend``
|
||||
if you have any custom logic to override Django's standard permissions checks.
|
||||
|
||||
**3.** Add ``axes.middleware.AxesMiddleware`` to your list of ``MIDDLEWARE``::
|
||||
|
||||
|
|
@ -56,9 +56,6 @@ if you have any custom logic to override Django's standard permissions checks.
|
|||
# on failed user authentication attempts from login views.
|
||||
# If you do not want Axes to override the authentication response
|
||||
# you can skip installing the middleware and use your own views.
|
||||
# AxesMiddleware runs during the reponse phase. It does not conflict
|
||||
# with middleware that runs in the request phase like
|
||||
# django.middleware.cache.FetchFromCacheMiddleware.
|
||||
'axes.middleware.AxesMiddleware',
|
||||
]
|
||||
|
||||
|
|
@ -78,90 +75,6 @@ Many people have different configurations for their development and production e
|
|||
and running the application with misconfigured settings can prevent security features from working.
|
||||
|
||||
|
||||
Version 8 breaking changes and upgrading from django-axes version 7
|
||||
-------------------------------------------------------------------
|
||||
|
||||
Some database related utility functions have moved from ``axes.helpers`` to ``axes.handlers.database`` module and under the ``axes.handlers.database.AxesDatabaseHandler`` class.
|
||||
|
||||
|
||||
Version 7 breaking changes and upgrading from django-axes version 6
|
||||
-------------------------------------------------------------------
|
||||
|
||||
If you use ``settings.AXES_COOLOFF_TIME`` for configuring a callable that returns the cooloff time, it needs to accept at minimum a ``request`` argument of type ``HttpRequest`` from version 7 onwards. Example: ``AXES_COOLOFF_TIME = lambda request: timedelta(hours=2)`` (new call signature) instead of ``AXES_COOLOFF_TIME = lambda: timedelta(hours=2)`` (old cal signature).
|
||||
|
||||
Please see configuration documentation and `jazzband/django-axes#1222 <https://github.com/jazzband/django-axes/pull/1222>`_ for reference.
|
||||
|
||||
|
||||
Version 6 breaking changes and upgrading from django-axes version 5
|
||||
-------------------------------------------------------------------
|
||||
|
||||
If you have not specialized ``django-axes`` configuration in any way
|
||||
you do not have to update any of the configuration.
|
||||
|
||||
The instructions apply to users who have configured ``django-axes`` in their projects
|
||||
and have used flags that are deprecated. The deprecated flags will be removed in the future
|
||||
but are compatible for at least version 6.0 of ``django-axes``.
|
||||
|
||||
The following flags and configuration have changed:
|
||||
|
||||
``django-ipware`` has become an optional dependency.
|
||||
To keep old behaviour, use ``pip install django-axes[ipware]``
|
||||
in your install script or use ``django-axes[ipware]``
|
||||
in your requirements file(s) instead of plain ``django-axes``.
|
||||
The new ``django-axes`` package does not include ``django-ipware`` by default
|
||||
but does use ``django-ipware`` if it is installed
|
||||
and no callables for IP address resolution are configured
|
||||
with the ``settings.AXES_CLIENT_IP_CALLABLE`` configuration flag.
|
||||
|
||||
``django-ipware`` related flags have changed names.
|
||||
The old flags have been deprecated and will be removed in the future.
|
||||
To keep old behaviour, rename them in your settings file:
|
||||
|
||||
- ``settings.AXES_PROXY_ORDER`` is now ``settings.AXES_IPWARE_PROXY_ORDER``,
|
||||
- ``settings.AXES_PROXY_COUNT`` is now ``settings.AXES_IPWARE_PROXY_COUNT``,
|
||||
- ``settings.AXES_PROXY_TRUSTED_IPS`` is now ``settings.AXES_IPWARE_PROXY_TRUSTED_IPS``, and
|
||||
- ``settings.AXES_META_PRECEDENCE_ORDER`` is now ``settings.AXES_IPWARE_META_PRECEDENCE_ORDER``.
|
||||
|
||||
``settings.AXES_LOCKOUT_PARAMETERS`` configuration flag has been added which supersedes the following configuration keys:
|
||||
|
||||
#. No configuration for failure tracking in the following items (default behaviour).
|
||||
#. ``settings.AXES_ONLY_USER_FAILURES``,
|
||||
#. ``settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP``,
|
||||
#. ``settings.AXES_LOCK_OUT_BY_USER_OR_IP``, and
|
||||
#. ``settings.AXES_USE_USER_AGENT``.
|
||||
|
||||
To keep old behaviour with the new flag, configure the following:
|
||||
|
||||
#. If you did not use any flags, use ``settings.AXES_LOCKOUT_PARAMETERS = ["ip_address"]``,
|
||||
#. If you used ``settings.AXES_ONLY_USER_FAILURES``, use ``settings.AXES_LOCKOUT_PARAMETERS = ["username"]``,
|
||||
#. If you used ``settings.AXES_LOCK_OUT_BY_USER_OR_IP``, use ``settings.AXES_LOCKOUT_PARAMETERS = ["username", "ip_address"]``, and
|
||||
#. If you used ``settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP``, use ``settings.AXES_LOCKOUT_PARAMETERS = [["username", "ip_address"]]``,
|
||||
#. If you used ``settings.AXES_USE_USER_AGENT``, add ``"user_agent"`` to your list(s) of lockout parameters.
|
||||
#. ``settings.AXES_USE_USER_AGENT`` would become ``settings.AXES_LOCKOUT_PARAMETERS = [["ip_address", "user_agent"]]``
|
||||
#. ``settings.AXES_USE_USER_AGENT`` with ``settings.AXES_ONLY_USER_FAILURES`` would become ``settings.AXES_LOCKOUT_PARAMETERS = [["username", "user_agent"]]``
|
||||
#. ``settings.AXES_USE_USER_AGENT`` with ``settings.AXES_LOCK_OUT_BY_USER_OR_IP`` would become ``settings.AXES_LOCKOUT_PARAMETERS = [["ip_address", "user_agent"], "username"]``
|
||||
#. ``settings.AXES_USE_USER_AGENT`` with ``settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP`` would become ``settings.AXES_LOCKOUT_PARAMETERS = [["ip_address", "user_agent", "username"]]``
|
||||
#. Other combinations of flags were previously not considered; the flags had precedence over each other as described in the documentation but were less-than-trivial to understand in their previous form. The new form is more explicit and flexible, although it requires more in-depth configuration.
|
||||
|
||||
The new lockout parameters define a combined list of attributes to consider when tracking failed authentication attempts.
|
||||
They can be any combination of ``username``, ``ip_address`` or ``user_agent`` in a list of strings or list of lists of strings.
|
||||
The attributes defined in the lists are combined and saved into the database, cache, or other backend for failed logins.
|
||||
The semantics of the evaluation are available in the documentation and ``axes.helpers.get_client_parameters`` callable.
|
||||
|
||||
``settings.AXES_HTTP_RESPONSE_CODE`` default has been changed from ``403`` (Forbidden) to ``429`` (Too Many Requests).
|
||||
To keep the old behavior, set ``settings.AXES_HTTP_RESPONSE_CODE = 403`` in your settings.
|
||||
|
||||
``axes.handlers.base.AxesBaseHandler.is_admin_site`` has been deprecated due to misleading naming
|
||||
in favour of better-named ``axes.handlers.base.AxesBaseHandler.is_admin_request``.
|
||||
The old implementation has been kept for backwards compatibility, but will be removed in the future.
|
||||
The old implementation checked if a request is NOT made for an admin site if ``settings.AXES_ONLY_ADMIN_SITE`` was set.
|
||||
The new implementation correctly checks if a request is made for an admin site.
|
||||
|
||||
``axes.handlers.cache.AxesCacheHandler`` has been updated to use atomic ``cache.incr`` calls
|
||||
instead of old ``cache.set`` calls in authentication failure tracking
|
||||
to enable better parallel backend support for atomic cache backends like Redis and Memcached.
|
||||
|
||||
|
||||
Disabling Axes system checks
|
||||
----------------------------
|
||||
|
||||
|
|
@ -215,7 +128,7 @@ other code, preventing the login mechanisms from working due to e.g. exception
|
|||
being thrown in some part of the code, preventing access attempts being logged
|
||||
to database with Axes or causing similar problems.
|
||||
|
||||
If new attempts or log objects are not being correctly written to the Axes tables,
|
||||
If new attempts or log objects are not being correctly written to the Axes tables,
|
||||
it is possible to configure Django ``ATOMIC_REQUESTS`` setting to to ``False``::
|
||||
|
||||
ATOMIC_REQUESTS = False
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ Resetting attempts from command line
|
|||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Axes offers a command line interface with
|
||||
``axes_reset``, ``axes_reset_ip``, ``axes_reset_username``, and ``axes_reset_ip_username``
|
||||
``axes_reset``, ``axes_reset_ip``, and ``axes_reset_username``
|
||||
management commands with the Django ``manage.py`` or ``django-admin`` command helpers:
|
||||
|
||||
- ``python manage.py axes_reset``
|
||||
|
|
@ -89,8 +89,6 @@ management commands with the Django ``manage.py`` or ``django-admin`` command he
|
|||
will clear lockouts and records for the given IP addresses.
|
||||
- ``python manage.py axes_reset_username [username ...]``
|
||||
will clear lockouts and records for the given usernames.
|
||||
- ``python manage.py axes_reset_ip_username [ip] [username]``
|
||||
will clear lockouts and records for the given IP address and username.
|
||||
- ``python manage.py axes_reset_logs (age)``
|
||||
will reset (i.e. delete) AccessLog records that are older
|
||||
than the given age where the default is 30 days.
|
||||
|
|
@ -109,24 +107,3 @@ In your code, you can use the ``axes.utils.reset`` function.
|
|||
Please note that if you give both ``username`` and ``ip`` arguments to ``reset``
|
||||
that attempts that have both the set IP and username are reset.
|
||||
The effective behaviour of ``reset`` is to ``and`` the terms instead of ``or`` ing them.
|
||||
|
||||
|
||||
|
||||
Data privacy and GDPR
|
||||
---------------------
|
||||
|
||||
Most European countries have quite strict laws regarding data protection and privacy. It's highly recommended and good
|
||||
practice to treat your sensitive user data with care. The general rule here is that you shouldn't store what you don't need.
|
||||
|
||||
When dealing with brute-force protection, the IP address and the username (often the email address) are most crucial.
|
||||
Given that you can perfectly use `django-axes` without locking the user out by IP but by username, it does make sense to
|
||||
avoid storing the IP address at all. You can not lose what you don't have.
|
||||
|
||||
You can adjust the AXES settings as follows::
|
||||
|
||||
# Block by Username only (i.e.: Same user different IP is still blocked, but different user same IP is not)
|
||||
AXES_LOCKOUT_PARAMETERS = ["username"]
|
||||
|
||||
# Disable logging the IP-Address of failed login attempts by returning None for attempts to get the IP
|
||||
# Ignore assigning a lambda function to a variable for brevity
|
||||
AXES_CLIENT_IP_CALLABLE = lambda x: None # noqa: E731
|
||||
|
|
|
|||
|
|
@ -23,11 +23,11 @@ The following ``settings.py`` options are available for customizing Axes behavio
|
|||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_LOCK_OUT_AT_FAILURE | True | After the number of allowed login attempts are exceeded, should we lock out this IP (and optional user agent)? |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_COOLOFF_TIME | None | If set, defines a period of inactivity after which old failed login attempts will be cleared. Can be set to a Python timedelta object, an integer, a float, a callable, or a string path to a callable which takes the request as argument. If an integer or float, will be interpreted as a number of hours: ``AXES_COOLOFF_TIME = 2`` 2 hours, ``AXES_COOLOFF_TIME = 2.0`` 2 hours, 120 minutes, ``AXES_COOLOFF_TIME = 1.7`` 1.7 hours, 102 minutes, 6120 seconds |
|
||||
| AXES_COOLOFF_TIME | None | If set, defines a period of inactivity after which old failed login attempts will be cleared. Can be set to a Python timedelta object, an integer, a float, a callable, or a string path to a callable which takes no arguments. If an integer or float, will be interpreted as a number of hours: ``AXES_COOLOFF_TIME = 2`` 2 hours, ``AXES_COOLOFF_TIME = 2.0`` 2 hours, 120 minutes, ``AXES_COOLOFF_TIME = 1.7`` 1.7 hours, 102 minutes, 6120 seconds |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_ONLY_ADMIN_SITE | False | If ``True``, lock is only enabled for admin site. Admin site is determined by checking request path against the path of ``"admin:index"`` view. If admin urls are not registered in current urlconf, all requests will not be locked. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_ONLY_USER_FAILURES | False | DEPRECATED: USE ``AXES_LOCKOUT_PARAMETERS`` INSTEAD. If ``True``, only lock based on username, and never lock based on IP if attempts exceed the limit. Otherwise utilize the existing IP and user locking logic. |
|
||||
| AXES_ONLY_USER_FAILURES | False | DEPRECATED: USE ``AXES_LOCKOUT_PARAMETERS ISNTEAD``. If ``True``, only lock based on username, and never lock based on IP if attempts exceed the limit. Otherwise utilize the existing IP and user locking logic. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_ENABLE_ADMIN | True | If ``True``, admin views for access attempts and logins are shown in Django admin interface. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
|
|
@ -47,19 +47,19 @@ The following ``settings.py`` options are available for customizing Axes behavio
|
|||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_VERBOSE | True | If ``True``, you'll see slightly more logging for Axes. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_USERNAME_FORM_FIELD | 'settings.AUTH_USER_MODEL.USERNAME_FIELD' | The name of the form field that contains your users usernames. |
|
||||
| AXES_USERNAME_FORM_FIELD | 'username' | The name of the form field that contains your users usernames. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_USERNAME_CALLABLE | None | A callable or a string path to callable that takes two arguments for user lookups: ``def get_username(request: HttpRequest, credentials: dict) -> str: ...``. This can be any callable such as ``AXES_USERNAME_CALLABLE = lambda request, credentials: 'username'`` or a full Python module path to callable such as ``AXES_USERNAME_CALLABLE = 'example.get_username``. The ``request`` is a HttpRequest like object and the ``credentials`` is a dictionary like object. ``credentials`` are the ones that were passed to Django ``authenticate()`` in the login flow. If no function is supplied, Axes fetches the username from the ``credentials`` or ``request.POST`` dictionaries based on ``AXES_USERNAME_FORM_FIELD``. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_WHITELIST_CALLABLE | None | A callable or a string path to callable that takes two arguments for whitelisting determination and returns True, if user should be whitelisted: ``def is_whitelisted(request: HttpRequest, credentials: dict) -> bool: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_LOCKOUT_CALLABLE | None | A callable or a string path to callable that takes three arguments returns a response. For example: ``def generate_lockout_response(request: HttpRequest, original_response: HttpResponse, credentials: dict) -> HttpResponse: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. If not callable is defined, then the default implementation in ``axes.helpers.get_lockout_response`` is used for determining the correct lockout response that is sent to the requesting client. |
|
||||
| AXES_LOCKOUT_CALLABLE | None | A callable or a string path to callable that takes two arguments returns a response. For example: ``def generate_lockout_response(request: HttpRequest, credentials: dict) -> HttpResponse: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. If not callable is defined, then the default implementation in ``axes.helpers.get_lockout_response`` is used for determining the correct lockout response that is sent to the requesting client. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_CLIENT_IP_CALLABLE | None | A callable or a string path to callable that takes HttpRequest as an argument and returns the resolved IP as a response. For example: ``def get_ip(request: HttpRequest) -> str: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. If not callable is defined, then the default implementation in ``axes.helpers.get_client_ip_address`` is used. |
|
||||
| AXES_CLIENT_IP_CALLABLE | None | A callable or a string path to callable that takes two arguments returns a response. For example: ``def get_ip(request: HttpRequest) -> str: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. If not callable is defined, then the default implementation in ``axes.helpers.get_client_ip_address`` is used. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_PASSWORD_FORM_FIELD | 'password' | The name of the form or credentials field that contains your users password. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_SENSITIVE_PARAMETERS | ["username", "ip_address"] | Configures POST and GET parameter values (in addition to the value of ``AXES_PASSWORD_FORM_FIELD``) to mask in login attempt logging. Defaults enable privacy-by-design. |
|
||||
| AXES_SENSITIVE_PARAMETERS | [] | Configures POST and GET parameter values (in addition to the value of ``AXES_PASSWORD_FORM_FIELD``) to mask in login attempt logging. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_NEVER_LOCKOUT_GET | False | If ``True``, Axes will never lock out HTTP GET requests. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
|
|
@ -109,8 +109,6 @@ following settings to suit your set up to correctly resolve client IP addresses:
|
|||
* ``AXES_IPWARE_META_PRECEDENCE_ORDER``: The names of ``request.META`` attributes as a tuple of strings
|
||||
to check to get the client IP address. Check the Django documentation for header naming conventions.
|
||||
Default: ``IPWARE_META_PRECEDENCE_ORDER`` setting if set, else ``('REMOTE_ADDR', )``
|
||||
* ``AXES_IPWARE_PROXY_ORDER``: The order in which to evaluate IP addresses from proxy headers when multiple IPs are present
|
||||
in the header chain. Must be either ``"left-most"`` or ``"right-most"``. **Default:** ``"left-most"``
|
||||
|
||||
.. note::
|
||||
For reverse proxies or e.g. Heroku, you might also want to fetch IP addresses from a HTTP header such as ``X-Forwarded-For``. To configure this, you can fetch IPs through the ``HTTP_X_FORWARDED_FOR`` key from the ``request.META`` property which contains all the HTTP headers in Django:
|
||||
|
|
@ -139,12 +137,6 @@ with the ``AXES_HANDLER`` setting in project configuration:
|
|||
logs attempts to database and creates AccessAttempt and AccessLog records
|
||||
that persist until removed from the database manually or automatically
|
||||
after their cool offs expire (checked on each login event).
|
||||
|
||||
.. note::
|
||||
To keep track of concurrent sessions AccessLog stores an hash of ``session_key`` if the session engine is configured.
|
||||
When no session engine is configured each access is stored with the same dummy value, then a logout will cause each *not-logged-out yet* logs to set a logout time.
|
||||
Due to how ``django.contrib.auth`` works it is not possible to correctly track the logout of a session in which the user changed its password, since it will create a new session without firing any logout event.
|
||||
|
||||
- ``axes.handlers.cache.AxesCacheHandler``
|
||||
only uses the cache for monitoring attempts and does not persist data
|
||||
other than in the cache backend; this data can be purged automatically
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ An example of usage could be e.g. a custom view for processing lockouts.
|
|||
|
||||
from django.http import JsonResponse
|
||||
|
||||
def lockout(request, response, credentials, *args, **kwargs):
|
||||
def lockout(request, credentials, *args, **kwargs):
|
||||
return JsonResponse({"status": "Locked out due to too many login failures"}, status=403)
|
||||
|
||||
``settings.py``::
|
||||
|
|
@ -188,7 +188,7 @@ Example ``AXES_LOCKOUT_PARAMETERS`` configuration:
|
|||
|
||||
AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]
|
||||
|
||||
This way, axes will lock out users using ip_address or combination of username and user_agent
|
||||
This way, axes will lock out users using ip_address and/or combination of username and user agent
|
||||
|
||||
Example of callable ``AXES_LOCKOUT_PARAMETERS``:
|
||||
|
||||
|
|
@ -203,17 +203,17 @@ Example of callable ``AXES_LOCKOUT_PARAMETERS``:
|
|||
|
||||
else:
|
||||
is_localhost = request_or_attempt.ip_address == "127.0.0.1"
|
||||
|
||||
|
||||
if is_localhost:
|
||||
return ["username"]
|
||||
|
||||
return ["username"]
|
||||
|
||||
return ["ip_address", "username"]
|
||||
|
||||
``settings.py``::
|
||||
|
||||
AXES_LOCKOUT_PARAMETERS = "example.utils.get_lockout_parameters"
|
||||
AXES_LOCKOUT_CALLABLE = "example.utils.get_lockout_parameters"
|
||||
|
||||
This way, if client ip_address is localhost, axes will lockout client only by username. In other case, axes will lockout client by username or ip_address.
|
||||
This way, if client ip_address is localhost, axes will lockout client only by username. In other case, axes will lockout client by username and/or ip_address.
|
||||
|
||||
Customizing client ip address lookups
|
||||
-------------------------------------
|
||||
|
|
@ -227,4 +227,4 @@ Axes can be configured with ``AXES_CLIENT_IP_CALLABLE`` to use custom client ip
|
|||
|
||||
``settings.py``::
|
||||
|
||||
AXES_CLIENT_IP_CALLABLE = "example.utils.get_client_ip"
|
||||
AXES_LOCKOUT_CALLABLE = "example.utils.get_client_ip"
|
||||
|
|
|
|||
9
docs/_static/css/custom_theme.css
vendored
9
docs/_static/css/custom_theme.css
vendored
|
|
@ -1,9 +0,0 @@
|
|||
@import url("theme.css");
|
||||
|
||||
.wy-nav-content {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.wy-table-responsive table td, .wy-table-responsive table th {
|
||||
white-space: inherit;
|
||||
}
|
||||
12
docs/conf.py
12
docs/conf.py
|
|
@ -6,8 +6,8 @@ More information on the configuration options is available at:
|
|||
https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
"""
|
||||
|
||||
# import sphinx_rtd_theme
|
||||
from importlib.metadata import version as get_version
|
||||
import sphinx_rtd_theme
|
||||
from pkg_resources import get_distribution
|
||||
|
||||
import django
|
||||
from django.conf import settings
|
||||
|
|
@ -25,7 +25,7 @@ description = ("Keep track of failed login attempts in Django-powered sites.",)
|
|||
|
||||
# Add any Sphinx extension module names here, as strings.
|
||||
# They can be extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
||||
extensions = ["sphinx_rtd_theme","sphinx.ext.autodoc"]
|
||||
extensions = ["sphinx.ext.autodoc"]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ["_templates"]
|
||||
|
|
@ -43,7 +43,7 @@ copyright = "2016, Jazzband"
|
|||
author = "Jazzband"
|
||||
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = get_version("django-axes")
|
||||
release = get_distribution("django-axes").version
|
||||
|
||||
# The short X.Y version.
|
||||
version = ".".join(release.split(".")[:2])
|
||||
|
|
@ -71,10 +71,8 @@ todo_include_todos = False
|
|||
# a list of builtin themes.
|
||||
html_theme = "sphinx_rtd_theme"
|
||||
|
||||
html_style = "css/custom_theme.css"
|
||||
|
||||
# Add any paths that contain custom themes here, relative to this directory.
|
||||
# html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
|
|
|
|||
2
mypy.ini
2
mypy.ini
|
|
@ -1,5 +1,5 @@
|
|||
[mypy]
|
||||
python_version = 3.14
|
||||
python_version = 3.7
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-axes.migrations.*]
|
||||
|
|
|
|||
|
|
@ -10,35 +10,36 @@ DJANGO_SETTINGS_MODULE = "tests.settings"
|
|||
legacy_tox_ini = """
|
||||
[tox]
|
||||
envlist =
|
||||
py{310,311,312}-dj42
|
||||
py{310,311,312,313}-dj52
|
||||
py{312,313,314}-dj60
|
||||
py314-djmain
|
||||
py314-djqa
|
||||
py{37,38,39,310,py38}-dj32
|
||||
py{38,39,310,311,py38}-dj41
|
||||
py{38,39,310,311,py38}-dj42
|
||||
py311-djmain
|
||||
py311-djqa
|
||||
|
||||
[gh-actions]
|
||||
python =
|
||||
3.7: py37
|
||||
3.8: py38
|
||||
3.9: py39
|
||||
3.10: py310
|
||||
3.11: py311
|
||||
3.12: py312
|
||||
3.13: py313
|
||||
3.14: py314
|
||||
pypy-3.8: pypy38
|
||||
|
||||
[gh-actions:env]
|
||||
DJANGO =
|
||||
3.2: dj32
|
||||
4.1: dj41
|
||||
4.2: dj42
|
||||
5.2: dj52
|
||||
6.0: dj60
|
||||
main: djmain
|
||||
qa: djqa
|
||||
|
||||
# Normal test environment runs pytest which orchestrates other tools
|
||||
[testenv]
|
||||
deps =
|
||||
-r requirements.txt
|
||||
dj42: django>=4.2,<4.3
|
||||
dj52: django>=5.2,<5.3
|
||||
dj60: django>=6.0,<6.1
|
||||
-r requirements-test.txt
|
||||
dj32: django>=3.2,<3.3
|
||||
dj41: django>=4.1,<4.2
|
||||
dj42: django>=4.1,<4.2
|
||||
djmain: https://github.com/django/django/archive/main.tar.gz
|
||||
usedevelop = true
|
||||
commands = pytest
|
||||
|
|
@ -47,15 +48,16 @@ setenv =
|
|||
# Django development version is allowed to fail the test matrix
|
||||
ignore_outcome =
|
||||
djmain: True
|
||||
pypy38: True
|
||||
ignore_errors =
|
||||
djmain: True
|
||||
pypy38: True
|
||||
|
||||
# QA runs type checks, linting, and code formatting checks
|
||||
[testenv:py314-djqa]
|
||||
stoponfail = false
|
||||
deps = -r requirements.txt
|
||||
[testenv:py311-djqa]
|
||||
deps = -r requirements-qa.txt
|
||||
commands =
|
||||
mypy axes
|
||||
prospector axes
|
||||
black --check --diff axes
|
||||
prospector
|
||||
black -t py38 --check --diff axes
|
||||
"""
|
||||
|
|
|
|||
4
requirements-qa.txt
Normal file
4
requirements-qa.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
black==23.3.0
|
||||
mypy==1.2.0
|
||||
prospector==1.10.0
|
||||
types-pkg_resources # Type stub
|
||||
7
requirements-test.txt
Normal file
7
requirements-test.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
-e .
|
||||
django-ipware>=3
|
||||
coverage==7.2.5
|
||||
pytest==7.3.1
|
||||
pytest-cov==4.0.0
|
||||
pytest-django==4.5.2
|
||||
pytest-subtests==0.10.0
|
||||
|
|
@ -1,12 +1,5 @@
|
|||
-e .
|
||||
black==26.3.1
|
||||
coverage==7.13.4
|
||||
django-ipware>=3
|
||||
mypy==1.19.1
|
||||
prospector==1.18.0
|
||||
pytest-cov==7.0.0
|
||||
pytest-django==4.12.0
|
||||
pytest-subtests==0.15.0
|
||||
pytest==9.0.2
|
||||
sphinx_rtd_theme==3.1.0
|
||||
tox==4.49.1
|
||||
-r requirements-qa.txt
|
||||
-r requirements-test.txt
|
||||
sphinx_rtd_theme==1.2.0
|
||||
tox==4.5.1
|
||||
|
|
|
|||
18
setup.py
18
setup.py
|
|
@ -35,11 +35,8 @@ setup(
|
|||
package_dir={"axes": "axes"},
|
||||
use_scm_version=True,
|
||||
setup_requires=["setuptools_scm"],
|
||||
python_requires=">=3.10",
|
||||
install_requires=[
|
||||
"django>=4.2",
|
||||
"asgiref>=3.6.0",
|
||||
],
|
||||
python_requires=">=3.7",
|
||||
install_requires=["django>=3.2", "setuptools"],
|
||||
extras_require={
|
||||
"ipware": "django-ipware>=3",
|
||||
},
|
||||
|
|
@ -50,21 +47,22 @@ setup(
|
|||
"Environment :: Web Environment",
|
||||
"Environment :: Plugins",
|
||||
"Framework :: Django",
|
||||
"Framework :: Django :: 3.2",
|
||||
"Framework :: Django :: 4.1",
|
||||
"Framework :: Django :: 4.2",
|
||||
"Framework :: Django :: 5.2",
|
||||
"Framework :: Django :: 6.0",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: System Administrators",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Programming Language :: Python :: Implementation :: PyPy",
|
||||
"Topic :: Internet :: Log Analysis",
|
||||
"Topic :: Security",
|
||||
"Topic :: System :: Logging",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ from string import ascii_letters, digits
|
|||
from time import sleep
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.http import HttpRequest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.test import override_settings, RequestFactory
|
||||
from django.test import override_settings
|
||||
from django.utils.timezone import now
|
||||
|
||||
from axes.attempts import get_cool_off_threshold
|
||||
|
|
@ -15,13 +15,12 @@ class GetCoolOffThresholdTestCase(AxesTestCase):
|
|||
def test_get_cool_off_threshold(self):
|
||||
timestamp = now()
|
||||
|
||||
request = RequestFactory().post("/")
|
||||
with patch("axes.attempts.now", return_value=timestamp):
|
||||
request.axes_attempt_time = timestamp
|
||||
threshold_now = get_cool_off_threshold(request)
|
||||
attempt_time = timestamp
|
||||
threshold_now = get_cool_off_threshold(attempt_time)
|
||||
|
||||
request.axes_attempt_time = None
|
||||
threshold_none = get_cool_off_threshold(request)
|
||||
attempt_time = None
|
||||
threshold_none = get_cool_off_threshold(attempt_time)
|
||||
|
||||
self.assertEqual(threshold_now, threshold_none)
|
||||
|
||||
|
|
|
|||
|
|
@ -110,43 +110,3 @@ class DeprecatedSettingsTestCase(AxesTestCase):
|
|||
def test_deprecated_success_access_log_flag(self):
|
||||
warnings = run_checks()
|
||||
self.assertEqual(warnings, [self.disable_success_access_log_warning])
|
||||
|
||||
|
||||
class ConfCheckTestCase(AxesTestCase):
|
||||
@override_settings(AXES_USERNAME_CALLABLE="module.not_defined")
|
||||
def test_invalid_import_path(self):
|
||||
warnings = run_checks()
|
||||
warning = Warning(
|
||||
msg=Messages.CALLABLE_INVALID.format(
|
||||
callable_setting="AXES_USERNAME_CALLABLE"
|
||||
),
|
||||
hint=Hints.CALLABLE_INVALID,
|
||||
id=Codes.CALLABLE_INVALID,
|
||||
)
|
||||
self.assertEqual(warnings, [warning])
|
||||
|
||||
@override_settings(AXES_COOLOFF_TIME=lambda: 1)
|
||||
def test_valid_callable(self):
|
||||
warnings = run_checks()
|
||||
self.assertEqual(warnings, [])
|
||||
|
||||
def test_missing_settings_no_error(self):
|
||||
warnings = run_checks()
|
||||
self.assertEqual(warnings, [])
|
||||
|
||||
|
||||
class LockoutParametersCheckTestCase(AxesTestCase):
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["ip_address", "username"])
|
||||
def test_valid_configuration(self):
|
||||
warnings = run_checks()
|
||||
self.assertEqual(warnings, [])
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "user_agent"])
|
||||
def test_invalid_configuration(self):
|
||||
warnings = run_checks()
|
||||
warning = Warning(
|
||||
msg=Messages.LOCKOUT_PARAMETERS_INVALID,
|
||||
hint=Hints.LOCKOUT_PARAMETERS_INVALID,
|
||||
id=Codes.LOCKOUT_PARAMETERS_INVALID,
|
||||
)
|
||||
self.assertEqual(warnings, [warning])
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
from django.test import TestCase
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
|
||||
|
||||
class ConfTestCase(TestCase):
|
||||
def test_axes_username_form_field_uses_lazy_evaluation(self):
|
||||
"""
|
||||
Test that AXES_USERNAME_FORM_FIELD uses SimpleLazyObject for lazy evaluation.
|
||||
This prevents circular import issues with custom user models (issue #1280).
|
||||
"""
|
||||
from axes.conf import settings
|
||||
|
||||
# Verify that AXES_USERNAME_FORM_FIELD is a SimpleLazyObject if not overridden
|
||||
# This is only the case when the setting is not explicitly defined
|
||||
username_field = settings.AXES_USERNAME_FORM_FIELD
|
||||
|
||||
# The actual type depends on whether AXES_USERNAME_FORM_FIELD was overridden
|
||||
# If it's using the default, it should be a SimpleLazyObject
|
||||
# If overridden in settings, it could be a plain string
|
||||
# Either way, it should be usable as a string
|
||||
|
||||
# Force evaluation and verify it works
|
||||
username_field_str = str(username_field)
|
||||
|
||||
# Should get the default USERNAME_FIELD from the user model
|
||||
# For the test suite, this is "username"
|
||||
self.assertIsInstance(username_field_str, str)
|
||||
self.assertTrue(len(username_field_str) > 0)
|
||||
|
||||
def test_axes_username_form_field_evaluates_correctly(self):
|
||||
"""
|
||||
Test that when AXES_USERNAME_FORM_FIELD is accessed, it correctly
|
||||
resolves to the user model's USERNAME_FIELD.
|
||||
"""
|
||||
from django.contrib.auth import get_user_model
|
||||
from axes.conf import settings
|
||||
|
||||
# Get the expected value
|
||||
expected_username_field = get_user_model().USERNAME_FIELD
|
||||
|
||||
# Get the actual value from axes settings
|
||||
actual_username_field = str(settings.AXES_USERNAME_FORM_FIELD)
|
||||
|
||||
# They should match
|
||||
self.assertEqual(actual_username_field, expected_username_field)
|
||||
|
|
@ -1,20 +1,18 @@
|
|||
from platform import python_implementation
|
||||
from unittest.mock import MagicMock, patch
|
||||
from datetime import datetime, timezone as dt_timezone
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
from axes.handlers.database import AxesDatabaseHandler
|
||||
from axes.models import AccessAttempt, AccessLog, AccessFailureLog, AccessAttemptExpiration
|
||||
|
||||
from pytest import mark
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import timedelta
|
||||
|
||||
from axes.conf import settings
|
||||
from axes.handlers.proxy import AxesProxyHandler
|
||||
from axes.helpers import get_client_str
|
||||
from axes.models import AccessAttempt, AccessLog, AccessFailureLog
|
||||
from tests.base import AxesTestCase
|
||||
|
||||
|
||||
|
|
@ -57,36 +55,14 @@ class AxesHandlerTestCase(AxesTestCase):
|
|||
for setting_value, url, expected in tests:
|
||||
with override_settings(AXES_ONLY_ADMIN_SITE=setting_value):
|
||||
request.path = url
|
||||
with self.assertWarns(DeprecationWarning):
|
||||
self.assertEqual(AxesProxyHandler().is_admin_site(request), expected)
|
||||
|
||||
def test_is_admin_request(self):
|
||||
request = MagicMock()
|
||||
tests = ( # (URL, Expected)
|
||||
("/test/", False),
|
||||
(reverse("admin:index"), True),
|
||||
)
|
||||
|
||||
for url, expected in tests:
|
||||
request.path = url
|
||||
self.assertEqual(AxesProxyHandler().is_admin_request(request), expected)
|
||||
self.assertEqual(AxesProxyHandler().is_admin_site(request), expected)
|
||||
|
||||
@override_settings(ROOT_URLCONF="tests.urls_empty")
|
||||
@override_settings(AXES_ONLY_ADMIN_SITE=True)
|
||||
def test_is_admin_site_no_admin_site(self):
|
||||
request = MagicMock()
|
||||
request.path = "/admin/"
|
||||
with self.assertWarns(DeprecationWarning):
|
||||
self.assertTrue(AxesProxyHandler().is_admin_site(self.request))
|
||||
|
||||
@override_settings(ROOT_URLCONF="tests.urls_empty")
|
||||
def test_is_admin_request_no_admin_site(self):
|
||||
request = MagicMock()
|
||||
request.path = "/admin/"
|
||||
self.assertFalse(AxesProxyHandler().is_admin_request(self.request))
|
||||
|
||||
def test_is_admin_request_no_path(self):
|
||||
self.assertFalse(AxesProxyHandler().is_admin_request(self.request))
|
||||
self.assertTrue(AxesProxyHandler().is_admin_site(self.request))
|
||||
|
||||
|
||||
class AxesProxyHandlerTestCase(AxesTestCase):
|
||||
|
|
@ -238,6 +214,11 @@ class ResetAttemptsTestCase(AxesHandlerBaseTestCase):
|
|||
AXES_RESET_ON_SUCCESS=True,
|
||||
AXES_ENABLE_ACCESS_FAILURE_LOG=True,
|
||||
)
|
||||
@mark.xfail(
|
||||
python_implementation() == "PyPy",
|
||||
reason="PyPy implementation is flaky for this test",
|
||||
strict=False,
|
||||
)
|
||||
class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase):
|
||||
def test_handler_reset_attempts(self):
|
||||
self.create_attempt()
|
||||
|
|
@ -569,170 +550,3 @@ class AxesTestHandlerTestCase(AxesHandlerBaseTestCase):
|
|||
|
||||
def test_handler_get_failures(self):
|
||||
self.assertEqual(0, AxesProxyHandler.get_failures(self.request, {}))
|
||||
|
||||
|
||||
@override_settings(AXES_HANDLER="axes.handlers.database.AxesDatabaseHandler", AXES_COOLOFF_TIME=timezone.timedelta(seconds=10))
|
||||
class AxesDatabaseHandlerExpirationFlagTestCase(AxesTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.handler = AxesDatabaseHandler()
|
||||
self.mock_request = MagicMock()
|
||||
self.mock_credentials = None
|
||||
|
||||
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=True)
|
||||
@patch("axes.handlers.database.log")
|
||||
@patch("axes.models.AccessAttempt.objects.filter")
|
||||
@patch("django.utils.timezone.now")
|
||||
def test_clean_expired_user_attempts_expiration_true(self, mock_now, mock_filter, mock_log):
|
||||
mock_now.return_value = datetime(2025, 1, 1, tzinfo=dt_timezone.utc)
|
||||
mock_qs = MagicMock()
|
||||
mock_filter.return_value = mock_qs
|
||||
mock_qs.delete.return_value = (3, None)
|
||||
|
||||
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
|
||||
mock_filter.assert_called_once_with(expiration__expires_at__lte=mock_now.return_value)
|
||||
mock_qs.delete.assert_called_once()
|
||||
mock_log.info.assert_called_with(
|
||||
"AXES: Cleaned up %s expired access attempts from database that expiry were older than %s",
|
||||
3,
|
||||
mock_now.return_value,
|
||||
)
|
||||
self.assertEqual(count, 3)
|
||||
|
||||
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=True)
|
||||
@patch("axes.handlers.database.log")
|
||||
def test_clean_expired_user_attempts_expiration_true_with_complete_deletion(self, mock_log):
|
||||
AccessAttempt.objects.all().delete()
|
||||
dummy_attempt = AccessAttempt.objects.create(
|
||||
username="test_user",
|
||||
ip_address="192.168.1.1",
|
||||
failures_since_start=1,
|
||||
user_agent="test_agent",
|
||||
)
|
||||
dummy_attempt.expiration = AccessAttemptExpiration.objects.create(
|
||||
access_attempt=dummy_attempt,
|
||||
expires_at=timezone.now() - timezone.timedelta(days=1) # Set to expire in the past
|
||||
)
|
||||
|
||||
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
|
||||
mock_log.info.assert_called_once()
|
||||
|
||||
# comparing count=2, as one is the dummy attempt and one is the expiration
|
||||
self.assertEqual(count, 2)
|
||||
self.assertEqual(
|
||||
AccessAttempt.objects.count(), 0
|
||||
)
|
||||
self.assertEqual(
|
||||
AccessAttemptExpiration.objects.count(), 0
|
||||
)
|
||||
|
||||
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=True)
|
||||
@patch("axes.handlers.database.log")
|
||||
def test_clean_expired_user_attempts_expiration_true_with_partial_deletion(self, mock_log):
|
||||
|
||||
attempt_not_expired = AccessAttempt.objects.create(
|
||||
username="test_user",
|
||||
ip_address="192.168.1.1",
|
||||
failures_since_start=1,
|
||||
user_agent="test_agent",
|
||||
)
|
||||
attempt_not_expired.expiration = AccessAttemptExpiration.objects.create(
|
||||
access_attempt=attempt_not_expired,
|
||||
expires_at=timezone.now() + timezone.timedelta(days=1) # Set to expire in the future
|
||||
)
|
||||
|
||||
attempt_expired = AccessAttempt.objects.create(
|
||||
username="test_user_2",
|
||||
ip_address="192.168.1.2",
|
||||
failures_since_start=1,
|
||||
user_agent="test_agent",
|
||||
)
|
||||
attempt_expired.expiration = AccessAttemptExpiration.objects.create(
|
||||
access_attempt=attempt_expired,
|
||||
expires_at=timezone.now() - timezone.timedelta(days=1) # Set to expire in the past
|
||||
)
|
||||
|
||||
access_attempt_count = AccessAttempt.objects.count()
|
||||
access_attempt_expiration_count = AccessAttemptExpiration.objects.count()
|
||||
|
||||
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
|
||||
mock_log.info.assert_called_once()
|
||||
|
||||
# comparing count=2, as one is the dummy attempt and one is the expiration
|
||||
self.assertEqual(count, 2)
|
||||
self.assertEqual(
|
||||
AccessAttempt.objects.count(), access_attempt_count - 1
|
||||
)
|
||||
self.assertEqual(
|
||||
AccessAttemptExpiration.objects.count(), access_attempt_expiration_count - 1
|
||||
)
|
||||
|
||||
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=True)
|
||||
@patch("axes.handlers.database.log")
|
||||
def test_clean_expired_user_attempts_expiration_true_with_no_deletion(self, mock_log):
|
||||
|
||||
attempt_not_expired_1 = AccessAttempt.objects.create(
|
||||
username="test_user",
|
||||
ip_address="192.168.1.1",
|
||||
failures_since_start=1,
|
||||
user_agent="test_agent",
|
||||
)
|
||||
attempt_not_expired_1.expiration = AccessAttemptExpiration.objects.create(
|
||||
access_attempt=attempt_not_expired_1,
|
||||
expires_at=timezone.now() + timezone.timedelta(days=1) # Set to expire in the future
|
||||
)
|
||||
|
||||
attempt_not_expired_2 = AccessAttempt.objects.create(
|
||||
username="test_user_2",
|
||||
ip_address="192.168.1.2",
|
||||
failures_since_start=1,
|
||||
user_agent="test_agent",
|
||||
)
|
||||
attempt_not_expired_2.expiration = AccessAttemptExpiration.objects.create(
|
||||
access_attempt=attempt_not_expired_2,
|
||||
expires_at=timezone.now() + timezone.timedelta(days=2) # Set to expire in the future
|
||||
)
|
||||
|
||||
access_attempt_count = AccessAttempt.objects.count()
|
||||
access_attempt_expiration_count = AccessAttemptExpiration.objects.count()
|
||||
|
||||
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
|
||||
mock_log.info.assert_called_once()
|
||||
|
||||
# comparing count=2, as one is the dummy attempt and one is the expiration
|
||||
self.assertEqual(count, 0)
|
||||
self.assertEqual(
|
||||
AccessAttempt.objects.count(), access_attempt_count
|
||||
)
|
||||
self.assertEqual(
|
||||
AccessAttemptExpiration.objects.count(), access_attempt_expiration_count
|
||||
)
|
||||
|
||||
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=False)
|
||||
@patch("axes.handlers.database.log")
|
||||
@patch("axes.handlers.database.get_cool_off_threshold")
|
||||
@patch("axes.models.AccessAttempt.objects.filter")
|
||||
def test_clean_expired_user_attempts_expiration_false(self, mock_filter, mock_get_threshold, mock_log):
|
||||
mock_get_threshold.return_value = "fake-threshold"
|
||||
mock_qs = MagicMock()
|
||||
mock_filter.return_value = mock_qs
|
||||
mock_qs.delete.return_value = (2, None)
|
||||
|
||||
count = self.handler.clean_expired_user_attempts(request=self.mock_request, credentials=None)
|
||||
mock_filter.assert_called_once_with(attempt_time__lte="fake-threshold")
|
||||
mock_qs.delete.assert_called_once()
|
||||
mock_log.info.assert_called_with(
|
||||
"AXES: Cleaned up %s expired access attempts from database that were older than %s",
|
||||
2,
|
||||
"fake-threshold",
|
||||
)
|
||||
self.assertEqual(count, 2)
|
||||
|
||||
@override_settings(AXES_COOLOFF_TIME=None)
|
||||
@patch("axes.handlers.database.log")
|
||||
def test_clean_expired_user_attempts_no_cooloff(self, mock_log):
|
||||
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
|
||||
mock_log.debug.assert_called_with(
|
||||
"AXES: Skipping clean for expired access attempts because no AXES_COOLOFF_TIME is configured"
|
||||
)
|
||||
self.assertEqual(count, 0)
|
||||
|
|
|
|||
|
|
@ -59,38 +59,6 @@ class CacheTestCase(AxesTestCase):
|
|||
def test_get_cache_timeout_none(self):
|
||||
self.assertEqual(get_cache_timeout(), None)
|
||||
|
||||
def test_get_increasing_cache_timeout_by_username(self):
|
||||
user_durations = {
|
||||
"ben": timedelta(minutes=5),
|
||||
"jen": timedelta(minutes=10),
|
||||
}
|
||||
|
||||
def _callback(request):
|
||||
username = request.POST["username"] if request else object()
|
||||
previous_duration = user_durations.get(username, timedelta())
|
||||
user_durations[username] = previous_duration + timedelta(minutes=5)
|
||||
return user_durations[username]
|
||||
|
||||
rf = RequestFactory()
|
||||
ben_req = rf.post("/", data={"username": "ben"})
|
||||
jen_req = rf.post("/", data={"username": "jen"})
|
||||
james_req = rf.post("/", data={"username": "james"})
|
||||
|
||||
with override_settings(AXES_COOLOFF_TIME=_callback):
|
||||
with self.subTest("no username"):
|
||||
self.assertEqual(get_cache_timeout(), 300)
|
||||
|
||||
with self.subTest("ben"):
|
||||
self.assertEqual(get_cache_timeout(ben_req), 600)
|
||||
self.assertEqual(get_cache_timeout(ben_req), 900)
|
||||
self.assertEqual(get_cache_timeout(ben_req), 1200)
|
||||
|
||||
with self.subTest("jen"):
|
||||
self.assertEqual(get_cache_timeout(jen_req), 900)
|
||||
|
||||
with self.subTest("james"):
|
||||
self.assertEqual(get_cache_timeout(james_req), 300)
|
||||
|
||||
|
||||
class TimestampTestCase(AxesTestCase):
|
||||
def test_iso8601(self):
|
||||
|
|
@ -114,7 +82,6 @@ class TimestampTestCase(AxesTestCase):
|
|||
self.assertEqual(get_cool_off_iso8601(delta), iso_duration)
|
||||
|
||||
|
||||
@override_settings(AXES_SENSITIVE_PARAMETERS=[])
|
||||
class ClientStringTestCase(AxesTestCase):
|
||||
@staticmethod
|
||||
def get_expected_client_str(*args, **kwargs):
|
||||
|
|
@ -947,7 +914,7 @@ class LockoutResponseTestCase(AxesTestCase):
|
|||
self.assertEqual(type(response), HttpResponse)
|
||||
|
||||
|
||||
def mock_get_cool_off_str(req):
|
||||
def mock_get_cool_off_str():
|
||||
return timedelta(seconds=30)
|
||||
|
||||
|
||||
|
|
@ -961,18 +928,18 @@ class AxesCoolOffTestCase(AxesTestCase):
|
|||
self.assertEqual(get_cool_off(), timedelta(hours=2))
|
||||
|
||||
@override_settings(AXES_COOLOFF_TIME=2.0)
|
||||
def test_get_cool_off_float(self):
|
||||
def test_get_cool_off_int(self):
|
||||
self.assertEqual(get_cool_off(), timedelta(minutes=120))
|
||||
|
||||
@override_settings(AXES_COOLOFF_TIME=0.25)
|
||||
def test_get_cool_off_float_lt_0(self):
|
||||
def test_get_cool_off_int(self):
|
||||
self.assertEqual(get_cool_off(), timedelta(minutes=15))
|
||||
|
||||
@override_settings(AXES_COOLOFF_TIME=1.7)
|
||||
def test_get_cool_off_float_gt_0(self):
|
||||
def test_get_cool_off_int(self):
|
||||
self.assertEqual(get_cool_off(), timedelta(seconds=6120))
|
||||
|
||||
@override_settings(AXES_COOLOFF_TIME=lambda r: timedelta(seconds=30))
|
||||
@override_settings(AXES_COOLOFF_TIME=lambda: timedelta(seconds=30))
|
||||
def test_get_cool_off_callable(self):
|
||||
self.assertEqual(get_cool_off(), timedelta(seconds=30))
|
||||
|
||||
|
|
@ -1013,16 +980,9 @@ def mock_get_lockout_response(request, credentials):
|
|||
return HttpResponse(status=400)
|
||||
|
||||
|
||||
def mock_get_lockout_response_with_original_response_param(
|
||||
request, response, credentials
|
||||
):
|
||||
return HttpResponse(status=400)
|
||||
|
||||
|
||||
class AxesLockoutTestCase(AxesTestCase):
|
||||
def setUp(self):
|
||||
self.request = HttpRequest()
|
||||
self.response = HttpResponse()
|
||||
self.credentials = dict()
|
||||
|
||||
def test_get_lockout_response(self):
|
||||
|
|
@ -1046,20 +1006,6 @@ class AxesLockoutTestCase(AxesTestCase):
|
|||
response = get_lockout_response(self.request, self.credentials)
|
||||
self.assertEqual(400, response.status_code)
|
||||
|
||||
@override_settings(
|
||||
AXES_LOCKOUT_CALLABLE=mock_get_lockout_response_with_original_response_param
|
||||
)
|
||||
def test_get_lockout_response_override_callable_with_original_response_param(self):
|
||||
response = get_lockout_response(self.request, self.response, self.credentials)
|
||||
self.assertEqual(400, response.status_code)
|
||||
|
||||
@override_settings(
|
||||
AXES_LOCKOUT_CALLABLE="tests.test_helpers.mock_get_lockout_response_with_original_response_param"
|
||||
)
|
||||
def test_get_lockout_response_override_path_with_original_response_param(self):
|
||||
response = get_lockout_response(self.request, self.response, self.credentials)
|
||||
self.assertEqual(400, response.status_code)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_CALLABLE=42)
|
||||
def test_get_lockout_response_override_invalid(self):
|
||||
with self.assertRaises(TypeError):
|
||||
|
|
@ -1074,7 +1020,6 @@ class AxesCleanseParamsTestCase(AxesTestCase):
|
|||
"other_sensitive_data": "sensitive",
|
||||
}
|
||||
|
||||
@override_settings(AXES_SENSITIVE_PARAMETERS=[])
|
||||
def test_cleanse_parameters(self):
|
||||
cleansed = cleanse_parameters(self.parameters)
|
||||
self.assertEqual("test_user", cleansed["username"])
|
||||
|
|
@ -1096,7 +1041,6 @@ class AxesCleanseParamsTestCase(AxesTestCase):
|
|||
self.assertEqual("********************", cleansed["password"])
|
||||
self.assertEqual("********************", cleansed["other_sensitive_data"])
|
||||
|
||||
@override_settings(AXES_SENSITIVE_PARAMETERS=[])
|
||||
@override_settings(AXES_PASSWORD_FORM_FIELD=None)
|
||||
def test_cleanse_parameters_override_empty(self):
|
||||
cleansed = cleanse_parameters(self.parameters)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from pkg_resources import get_distribution
|
||||
|
||||
from axes import __version__
|
||||
from axes.apps import AppConfig
|
||||
from axes.models import AccessAttempt, AccessLog
|
||||
from tests.base import AxesTestCase
|
||||
|
||||
_BEGIN = "AXES: BEGIN version %s, %s"
|
||||
_VERSION = __version__
|
||||
_VERSION = get_distribution("django-axes").version
|
||||
|
||||
|
||||
@patch("axes.apps.AppConfig.initialized", False)
|
||||
|
|
@ -58,21 +59,16 @@ class AppsTestCase(AxesTestCase):
|
|||
class AccessLogTestCase(AxesTestCase):
|
||||
def test_access_log_on_logout(self):
|
||||
"""
|
||||
Test a valid logout and make sure the logout_time is updated only for that.
|
||||
Test a valid logout and make sure the logout_time is updated.
|
||||
"""
|
||||
|
||||
self.login(is_valid_username=True, is_valid_password=True)
|
||||
latest_log = AccessLog.objects.latest("id")
|
||||
self.assertIsNone(latest_log.logout_time)
|
||||
other_log = self.create_log(session_hash='not-the-session')
|
||||
self.assertIsNone(other_log.logout_time)
|
||||
self.assertIsNone(AccessLog.objects.latest("id").logout_time)
|
||||
|
||||
response = self.logout()
|
||||
response = self.client.post(reverse("admin:logout"))
|
||||
self.assertContains(response, "Logged out")
|
||||
other_log.refresh_from_db()
|
||||
self.assertIsNone(other_log.logout_time)
|
||||
latest_log.refresh_from_db()
|
||||
self.assertIsNotNone(latest_log.logout_time)
|
||||
|
||||
self.assertIsNotNone(AccessLog.objects.latest("id").logout_time)
|
||||
|
||||
@override_settings(DATA_UPLOAD_MAX_NUMBER_FIELDS=1500)
|
||||
def test_log_data_truncated(self):
|
||||
|
|
@ -81,7 +77,7 @@ class AccessLogTestCase(AxesTestCase):
|
|||
"""
|
||||
|
||||
# An impossibly large post dict
|
||||
extra_data = {"too-large-field": "x" * 2 ** 16}
|
||||
extra_data = {"a" * x: x for x in range(1024)}
|
||||
self.login(**extra_data)
|
||||
self.assertEqual(len(AccessAttempt.objects.latest("id").post_data), 1024)
|
||||
|
||||
|
|
@ -90,7 +86,7 @@ class AccessLogTestCase(AxesTestCase):
|
|||
AccessLog.objects.all().delete()
|
||||
|
||||
response = self.login(is_valid_username=True, is_valid_password=True)
|
||||
response = self.logout()
|
||||
response = self.client.post(reverse("admin:logout"))
|
||||
|
||||
self.assertEqual(AccessLog.objects.all().count(), 0)
|
||||
self.assertContains(response, "Logged out", html=True)
|
||||
|
|
@ -113,7 +109,7 @@ class AccessLogTestCase(AxesTestCase):
|
|||
AccessLog.objects.all().delete()
|
||||
|
||||
response = self.login(is_valid_username=True, is_valid_password=True)
|
||||
response = self.logout()
|
||||
response = self.client.post(reverse("admin:logout"))
|
||||
|
||||
self.assertEqual(AccessLog.objects.count(), 0)
|
||||
self.assertContains(response, "Logged out", html=True)
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ class DjangoLoginTestCase(TestCase):
|
|||
self.username = "john.doe"
|
||||
self.password = "hunter2"
|
||||
|
||||
self.user = get_user_model().objects.create(username=self.username, is_staff=True)
|
||||
self.user = get_user_model().objects.create(username=self.username)
|
||||
self.user.set_password(self.password)
|
||||
self.user.save()
|
||||
self.user.backend = "django.contrib.auth.backends.ModelBackend"
|
||||
|
|
@ -47,19 +47,13 @@ class DjangoContribAuthLoginTestCase(DjangoLoginTestCase):
|
|||
class DjangoTestClientLoginTestCase(DjangoLoginTestCase):
|
||||
def test_client_login(self):
|
||||
self.client.login(username=self.username, password=self.password)
|
||||
response = self.client.get(reverse("admin:index"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_client_logout(self):
|
||||
self.client.login(username=self.username, password=self.password)
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse("admin:index"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_client_force_login(self):
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("admin:index"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class DatabaseLoginTestCase(AxesTestCase):
|
||||
|
|
@ -289,7 +283,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
# Test he is locked by user_agent:
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
|
||||
# Test he is allowed to login with different username, ip and user_agent
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser2")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
|
@ -314,7 +308,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
# Test he is allowed to login with different user_agent:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser2")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
||||
|
||||
# Test he is allowed to login with different username, ip and user_agent
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser2")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
|
|
|||
|
|
@ -56,22 +56,18 @@ class ManagementCommandTestCase(AxesTestCase):
|
|||
username="john.doe", ip_address="10.0.0.2", failures_since_start="15"
|
||||
)
|
||||
|
||||
AccessAttempt.objects.create(
|
||||
username="richard.doe", ip_address="10.0.0.4", failures_since_start="12"
|
||||
)
|
||||
|
||||
def test_axes_list_attempts(self):
|
||||
out = StringIO()
|
||||
call_command("axes_list_attempts", stdout=out)
|
||||
|
||||
expected = "10.0.0.1\tjane.doe\t4\n10.0.0.2\tjohn.doe\t15\n10.0.0.4\trichard.doe\t12\n"
|
||||
expected = "10.0.0.1\tjane.doe\t4\n10.0.0.2\tjohn.doe\t15\n"
|
||||
self.assertEqual(expected, out.getvalue())
|
||||
|
||||
def test_axes_reset(self):
|
||||
out = StringIO()
|
||||
call_command("axes_reset", stdout=out)
|
||||
|
||||
expected = "3 attempts removed.\n"
|
||||
expected = "2 attempts removed.\n"
|
||||
self.assertEqual(expected, out.getvalue())
|
||||
|
||||
def test_axes_reset_not_found(self):
|
||||
|
|
@ -91,13 +87,6 @@ class ManagementCommandTestCase(AxesTestCase):
|
|||
expected = "1 attempts removed.\n"
|
||||
self.assertEqual(expected, out.getvalue())
|
||||
|
||||
def test_axes_reset_ip_username(self):
|
||||
out = StringIO()
|
||||
call_command("axes_reset_ip_username", "10.0.0.4", "richard.doe", stdout=out)
|
||||
|
||||
expected = "1 attempts removed.\n"
|
||||
self.assertEqual(expected, out.getvalue())
|
||||
|
||||
def test_axes_reset_ip_not_found(self):
|
||||
out = StringIO()
|
||||
call_command("axes_reset_ip", "10.0.0.3", stdout=out)
|
||||
|
|
|
|||
Loading…
Reference in a new issue