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.0b3" have entirely different histories.
57 changed files with 495 additions and 2463 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
|
||||
222
CHANGES.rst
222
CHANGES.rst
|
|
@ -2,224 +2,28 @@
|
|||
Changes
|
||||
=======
|
||||
|
||||
8.3.1 (2026-02-11)
|
||||
------------------
|
||||
|
||||
- Fix configuration JSON serialization errors for Celery.
|
||||
[aleksihakli]
|
||||
6.0.0b3 (2023-05-01)
|
||||
--------------------
|
||||
|
||||
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]
|
||||
- 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]
|
||||
|
||||
|
||||
|
|
@ -317,7 +121,7 @@ Version 6 is a breaking release. Please see the documentation for upgrade instru
|
|||
5.32.0 (2022-04-08)
|
||||
-------------------
|
||||
|
||||
- Add support for persistent failure logging
|
||||
- Add support for persistent logging
|
||||
where failed login attempts are persisted in the database
|
||||
until a specific threshold is reached.
|
||||
[p1-gdd]
|
||||
|
|
|
|||
|
|
@ -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")}),
|
||||
)
|
||||
|
||||
|
|
|
|||
23
axes/apps.py
23
axes/apps.py
|
|
@ -33,23 +33,14 @@ class AppConfig(apps.AppConfig):
|
|||
|
||||
# Skip startup log messages if Axes is not set to verbose
|
||||
if settings.AXES_VERBOSE:
|
||||
if callable(settings.AXES_LOCKOUT_PARAMETERS) or isinstance(
|
||||
settings.AXES_LOCKOUT_PARAMETERS, str
|
||||
):
|
||||
mode = "blocking by parameters that are calculated in a custom callable"
|
||||
|
||||
if settings.AXES_ONLY_USER_FAILURES:
|
||||
mode = "blocking by username only"
|
||||
elif settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP:
|
||||
mode = "blocking by combination of username and IP"
|
||||
elif settings.AXES_LOCK_OUT_BY_USER_OR_IP:
|
||||
mode = "blocking by username or IP"
|
||||
else:
|
||||
mode = "blocking by " + " or ".join(
|
||||
[
|
||||
(
|
||||
param
|
||||
if isinstance(param, str)
|
||||
else "combination of " + " and ".join(param)
|
||||
)
|
||||
for param in settings.AXES_LOCKOUT_PARAMETERS
|
||||
]
|
||||
)
|
||||
|
||||
mode = "blocking by IP only"
|
||||
log.info(
|
||||
"AXES: BEGIN version %s, %s",
|
||||
__version__,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
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)
|
||||
|
|
@ -137,13 +128,6 @@ def axes_deprecation_check(app_configs, **kwargs): # pylint: disable=unused-arg
|
|||
"AXES_PROXY_COUNT",
|
||||
"AXES_PROXY_TRUSTED_IPS",
|
||||
"AXES_META_PRECEDENCE_ORDER",
|
||||
# AXES_ONLY_USER_FAILURES, AXES_USE_USER_AGENT and
|
||||
# AXES_LOCK_OUT parameters were replaced with AXES_LOCKOUT_PARAMETERS
|
||||
# in version 6.x
|
||||
"AXES_ONLY_USER_FAILURES",
|
||||
"AXES_LOCK_OUT_BY_USER_OR_IP",
|
||||
"AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP",
|
||||
"AXES_USE_USER_AGENT",
|
||||
]
|
||||
|
||||
for deprecated_setting in deprecated_settings:
|
||||
|
|
@ -162,77 +146,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
|
||||
|
|
|
|||
63
axes/conf.py
63
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)
|
||||
|
||||
|
|
@ -25,30 +10,18 @@ settings.AXES_FAILURE_LIMIT = getattr(settings, "AXES_FAILURE_LIMIT", 3)
|
|||
# see if the user has set axes to lock out logins after failure limit
|
||||
settings.AXES_LOCK_OUT_AT_FAILURE = getattr(settings, "AXES_LOCK_OUT_AT_FAILURE", True)
|
||||
|
||||
# lockout parameters
|
||||
# default value will be ["ip_address"] after removing AXES_LOCK_OUT params support
|
||||
settings.AXES_LOCKOUT_PARAMETERS = getattr(settings, "AXES_LOCKOUT_PARAMETERS", None)
|
||||
# lock out with the combination of username and IP address
|
||||
settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP = getattr(
|
||||
settings, "AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP", False
|
||||
)
|
||||
|
||||
# TODO: remove it in future versions
|
||||
if settings.AXES_LOCKOUT_PARAMETERS is None:
|
||||
if getattr(settings, "AXES_ONLY_USER_FAILURES", False):
|
||||
settings.AXES_LOCKOUT_PARAMETERS = ["username"]
|
||||
else:
|
||||
if getattr(settings, "AXES_LOCK_OUT_BY_USER_OR_IP", False):
|
||||
settings.AXES_LOCKOUT_PARAMETERS = ["username", "ip_address"]
|
||||
elif getattr(settings, "AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP", False):
|
||||
settings.AXES_LOCKOUT_PARAMETERS = [["username", "ip_address"]]
|
||||
else:
|
||||
settings.AXES_LOCKOUT_PARAMETERS = ["ip_address"]
|
||||
# lock out with the username or IP address
|
||||
settings.AXES_LOCK_OUT_BY_USER_OR_IP = getattr(
|
||||
settings, "AXES_LOCK_OUT_BY_USER_OR_IP", False
|
||||
)
|
||||
|
||||
if getattr(settings, "AXES_USE_USER_AGENT", False):
|
||||
if isinstance(settings.AXES_LOCKOUT_PARAMETERS[0], str):
|
||||
settings.AXES_LOCKOUT_PARAMETERS[0] = [
|
||||
settings.AXES_LOCKOUT_PARAMETERS[0],
|
||||
"user_agent",
|
||||
]
|
||||
else:
|
||||
settings.AXES_LOCKOUT_PARAMETERS[0].append("user_agent")
|
||||
# lock out with username and never the IP or user agent
|
||||
settings.AXES_ONLY_USER_FAILURES = getattr(settings, "AXES_ONLY_USER_FAILURES", False)
|
||||
|
||||
# lock out just for admin site
|
||||
settings.AXES_ONLY_ADMIN_SITE = getattr(settings, "AXES_ONLY_ADMIN_SITE", False)
|
||||
|
|
@ -56,16 +29,12 @@ 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)
|
||||
|
||||
# lock out with the user agent, has no effect when ONLY_USER_FAILURES is set
|
||||
settings.AXES_USE_USER_AGENT = getattr(settings, "AXES_USE_USER_AGENT", False)
|
||||
|
||||
# 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 +77,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 +115,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")
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ from axes.helpers import (
|
|||
get_client_username,
|
||||
get_credentials,
|
||||
get_failure_limit,
|
||||
get_lockout_parameters,
|
||||
)
|
||||
from axes.models import AccessAttempt
|
||||
from axes.signals import user_locked_out
|
||||
|
|
@ -79,10 +78,9 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
return
|
||||
|
||||
username = get_client_username(request, credentials)
|
||||
lockout_parameters = get_lockout_parameters(request, credentials)
|
||||
if lockout_parameters == ["username"] and username is None:
|
||||
if settings.AXES_ONLY_USER_FAILURES and username is None:
|
||||
log.warning(
|
||||
"AXES: Username is None and username is the only one lockout parameter, new record will NOT be created."
|
||||
"AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, new record will NOT be created."
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -113,7 +111,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,26 @@
|
|||
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 +102,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 +115,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 +130,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(
|
||||
|
|
@ -170,13 +164,12 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
return
|
||||
|
||||
# 2. database query: Get or create access record with the new failure data
|
||||
lockout_parameters = get_lockout_parameters(request, credentials)
|
||||
if lockout_parameters == ["username"] and username is None:
|
||||
if settings.AXES_ONLY_USER_FAILURES and username is None:
|
||||
log.warning(
|
||||
"AXES: Username is None and username is the only one lockout parameter, new record will NOT be created."
|
||||
"AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, new record will NOT be created."
|
||||
)
|
||||
else:
|
||||
with transaction.atomic(using=router.db_for_write(AccessAttempt)):
|
||||
with transaction.atomic():
|
||||
(
|
||||
attempt,
|
||||
created,
|
||||
|
|
@ -225,23 +218,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 +241,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 +258,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 +273,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 +282,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 +298,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 +310,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.
|
||||
|
|
|
|||
177
axes/helpers.py
177
axes/helpers.py
|
|
@ -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"
|
||||
|
|
@ -237,33 +217,7 @@ def get_client_http_accept(request: HttpRequest) -> str:
|
|||
return request.META.get("HTTP_ACCEPT", "<unknown>")[:1025]
|
||||
|
||||
|
||||
def get_lockout_parameters(
|
||||
request_or_attempt: Union[HttpRequest, AccessBase],
|
||||
credentials: Optional[dict] = None,
|
||||
) -> List[Union[str, List[str]]]:
|
||||
if callable(settings.AXES_LOCKOUT_PARAMETERS):
|
||||
return settings.AXES_LOCKOUT_PARAMETERS(request_or_attempt, credentials)
|
||||
|
||||
if isinstance(settings.AXES_LOCKOUT_PARAMETERS, str):
|
||||
return import_string(settings.AXES_LOCKOUT_PARAMETERS)(
|
||||
request_or_attempt, credentials
|
||||
)
|
||||
|
||||
if isinstance(settings.AXES_LOCKOUT_PARAMETERS, list):
|
||||
return settings.AXES_LOCKOUT_PARAMETERS
|
||||
|
||||
raise TypeError(
|
||||
"settings.AXES_LOCKOUT_PARAMETERS needs to be a callable or iterable"
|
||||
)
|
||||
|
||||
|
||||
def get_client_parameters(
|
||||
username: str,
|
||||
ip_address: str,
|
||||
user_agent: str,
|
||||
request_or_attempt: Union[HttpRequest, AccessBase],
|
||||
credentials: Optional[dict] = None,
|
||||
) -> List[dict]:
|
||||
def get_client_parameters(username: str, ip_address: str, user_agent: str) -> list:
|
||||
"""
|
||||
Get query parameters for filtering AccessAttempt queryset.
|
||||
|
||||
|
|
@ -272,36 +226,26 @@ def get_client_parameters(
|
|||
|
||||
Returns list of dict, every item of list are separate parameters
|
||||
"""
|
||||
lockout_parameters = get_lockout_parameters(request_or_attempt, credentials)
|
||||
|
||||
parameters_dict = {
|
||||
"username": username,
|
||||
"ip_address": ip_address,
|
||||
"user_agent": user_agent,
|
||||
}
|
||||
if settings.AXES_ONLY_USER_FAILURES:
|
||||
# 1. Only individual usernames can be tracked with parametrization
|
||||
filter_query = [{"username": username}]
|
||||
else:
|
||||
if settings.AXES_LOCK_OUT_BY_USER_OR_IP:
|
||||
# One of `username` or `IP address` is used
|
||||
filter_query = [{"username": username}, {"ip_address": ip_address}]
|
||||
elif settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP:
|
||||
# 2. A combination of username and IP address can be used as well
|
||||
filter_query = [{"username": username, "ip_address": ip_address}]
|
||||
else:
|
||||
# 3. Default case is to track the IP address only, which is the most secure option
|
||||
filter_query = [{"ip_address": ip_address}]
|
||||
|
||||
filter_kwargs = []
|
||||
if settings.AXES_USE_USER_AGENT:
|
||||
# 4. The HTTP User-Agent can be used to track e.g. one browser
|
||||
filter_query[0]["user_agent"] = user_agent
|
||||
|
||||
for parameter in lockout_parameters:
|
||||
try:
|
||||
if isinstance(parameter, str):
|
||||
filter_kwarg = {parameter: parameters_dict[parameter]}
|
||||
else:
|
||||
filter_kwarg = {
|
||||
combined_parameter: parameters_dict[combined_parameter]
|
||||
for combined_parameter in parameter
|
||||
}
|
||||
filter_kwargs.append(filter_kwarg)
|
||||
|
||||
except KeyError as e:
|
||||
error_msg = (
|
||||
f"{e} lockout parameter is not allowed. "
|
||||
f"Allowed parameters: {', '.join(parameters_dict.keys())}"
|
||||
)
|
||||
log.exception(error_msg)
|
||||
raise ValueError(error_msg) from e
|
||||
|
||||
return filter_kwargs
|
||||
return filter_query
|
||||
|
||||
|
||||
def make_cache_key_list(filter_kwargs_list: List[dict]) -> List[str]:
|
||||
|
|
@ -336,9 +280,7 @@ def get_client_cache_keys(
|
|||
ip_address = get_client_ip_address(request_or_attempt)
|
||||
user_agent = get_client_user_agent(request_or_attempt)
|
||||
|
||||
filter_kwargs_list = get_client_parameters(
|
||||
username, ip_address, user_agent, request_or_attempt, credentials
|
||||
)
|
||||
filter_kwargs_list = get_client_parameters(username, ip_address, user_agent)
|
||||
|
||||
return make_cache_key_list(filter_kwargs_list)
|
||||
|
||||
|
|
@ -381,7 +323,7 @@ def get_client_str(
|
|||
client_dict["user_agent"] = user_agent
|
||||
else:
|
||||
# Other modes initialize the attributes that are used for the actual lockouts
|
||||
client_list = get_client_parameters(username, ip_address, user_agent, request)
|
||||
client_list = get_client_parameters(username, ip_address, user_agent)
|
||||
client_dict = {}
|
||||
for client in client_list:
|
||||
client_dict.update(client)
|
||||
|
|
@ -462,27 +404,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 +423,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 +436,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 +576,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"
|
||||
Binary file not shown.
|
|
@ -8,7 +8,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-05-13 12:36+0500\n"
|
||||
"POT-Creation-Date: 2019-01-11 12:20+0300\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"
|
||||
|
|
@ -18,92 +18,80 @@ msgstr ""
|
|||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#: axes/admin.py:27
|
||||
#: axes/admin.py:38
|
||||
msgid "Form Data"
|
||||
msgstr "Данные формы"
|
||||
|
||||
#: axes/admin.py:28 axes/admin.py:65 axes/admin.py:100
|
||||
#: axes/admin.py:41 axes/admin.py:95
|
||||
msgid "Meta Data"
|
||||
msgstr "Метаданные"
|
||||
|
||||
#: axes/conf.py:99
|
||||
#: axes/conf.py:58
|
||||
msgid "Account locked: too many login attempts. Please try again later."
|
||||
msgstr ""
|
||||
"Учетная запись заблокирована: слишком много попыток входа. Повторите попытку "
|
||||
"позже."
|
||||
"Учетная запись заблокирована: слишком много попыток входа. "
|
||||
"Повторите попытку позже."
|
||||
|
||||
#: axes/conf.py:107
|
||||
#: axes/conf.py:61
|
||||
msgid ""
|
||||
"Account locked: too many login attempts. Contact an admin to unlock your "
|
||||
"account."
|
||||
msgstr ""
|
||||
"Учетная запись заблокирована: слишком много попыток входа. Свяжитесь с "
|
||||
"администратором, чтобы разблокировать учетную запись."
|
||||
"Учетная запись заблокирована: слишком много попыток входа. "
|
||||
"Обратитесь к администратору для разблокирования учетной записи."
|
||||
|
||||
#: axes/models.py:6
|
||||
#: axes/models.py:9
|
||||
msgid "User Agent"
|
||||
msgstr "User Agent"
|
||||
msgstr "Браузер пользователя"
|
||||
|
||||
#: axes/models.py:8
|
||||
#: axes/models.py:15
|
||||
msgid "IP Address"
|
||||
msgstr "IP Адрес"
|
||||
msgstr "Адрес IP"
|
||||
|
||||
#: axes/models.py:10
|
||||
#: axes/models.py:21
|
||||
msgid "Username"
|
||||
msgstr "Имя пользователя"
|
||||
msgstr "Пользователь"
|
||||
|
||||
#: axes/models.py:12
|
||||
#: axes/models.py:35
|
||||
msgid "HTTP Accept"
|
||||
msgstr "HTTP Accept"
|
||||
msgstr "Запрос HTTP"
|
||||
|
||||
#: axes/models.py:14
|
||||
#: axes/models.py:40
|
||||
msgid "Path"
|
||||
msgstr "Путь"
|
||||
|
||||
#: axes/models.py:16
|
||||
#: axes/models.py:45
|
||||
msgid "Attempt Time"
|
||||
msgstr "Время попытки входа"
|
||||
msgstr "Время входа"
|
||||
|
||||
#: axes/models.py:26
|
||||
msgid "Access lock out"
|
||||
msgstr "Доступ запрещен"
|
||||
|
||||
#: axes/models.py:34
|
||||
msgid "access failure"
|
||||
msgstr "Ошибка доступа"
|
||||
|
||||
#: axes/models.py:35
|
||||
msgid "access failures"
|
||||
msgstr "Ошибки доступа"
|
||||
|
||||
#: axes/models.py:39
|
||||
#: axes/models.py:57
|
||||
msgid "GET Data"
|
||||
msgstr "Данные GET-запроса"
|
||||
|
||||
#: axes/models.py:41
|
||||
#: axes/models.py:61
|
||||
msgid "POST Data"
|
||||
msgstr "Данные POST-запроса"
|
||||
|
||||
#: axes/models.py:43
|
||||
#: axes/models.py:65
|
||||
msgid "Failed Logins"
|
||||
msgstr "Ошибочные попытки"
|
||||
|
||||
#: axes/models.py:49
|
||||
#: axes/models.py:76
|
||||
msgid "access attempt"
|
||||
msgstr "Запись о попытке доступа"
|
||||
|
||||
#: axes/models.py:50
|
||||
#: axes/models.py:77
|
||||
msgid "access attempts"
|
||||
msgstr "Попытки доступа"
|
||||
|
||||
#: axes/models.py:55
|
||||
#: axes/models.py:81
|
||||
msgid "Logout Time"
|
||||
msgstr "Время выхода"
|
||||
|
||||
#: axes/models.py:61
|
||||
#: axes/models.py:90
|
||||
msgid "access log"
|
||||
msgstr "Запись о доступе"
|
||||
|
||||
#: axes/models.py:62
|
||||
#: axes/models.py:91
|
||||
msgid "access logs"
|
||||
msgstr "Логи доступа"
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ from typing import Optional
|
|||
|
||||
from django.http import HttpRequest
|
||||
|
||||
from axes.conf import settings
|
||||
from axes.handlers.proxy import AxesProxyHandler
|
||||
from axes.helpers import get_client_ip_address, get_lockout_parameters
|
||||
from axes.helpers import get_client_ip_address
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
|
@ -36,38 +37,23 @@ def reset_request(request: HttpRequest) -> int:
|
|||
|
||||
This utility method is meant to be used from the CLI or via Python API.
|
||||
"""
|
||||
lockout_paramaters = get_lockout_parameters(request)
|
||||
|
||||
ip: Optional[str] = get_client_ip_address(request)
|
||||
username = request.GET.get("username", None)
|
||||
|
||||
ip_required = False
|
||||
username_required = False
|
||||
ip_and_username = False
|
||||
|
||||
for param in lockout_paramaters:
|
||||
# hack: in works with all iterables, including strings
|
||||
# so this checks works with separate parameters
|
||||
# and with parameters combinations
|
||||
if "username" in param and "ip_address" in param:
|
||||
ip_and_username = True
|
||||
ip_required = True
|
||||
username_required = True
|
||||
break
|
||||
if "username" in param:
|
||||
username_required = True
|
||||
elif "ip_address" in param:
|
||||
ip_required = True
|
||||
|
||||
ip_or_username = not ip_and_username and ip_required and username_required
|
||||
if not ip_required:
|
||||
ip_or_username = settings.AXES_LOCK_OUT_BY_USER_OR_IP
|
||||
if settings.AXES_ONLY_USER_FAILURES:
|
||||
ip = None
|
||||
if not username_required:
|
||||
elif not (
|
||||
settings.AXES_LOCK_OUT_BY_USER_OR_IP
|
||||
or settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP
|
||||
):
|
||||
username = None
|
||||
|
||||
if not ip and not username:
|
||||
return 0
|
||||
# We don't want to reset everything, if there is some wrong request parameter
|
||||
|
||||
# if settings.AXES_USE_USER_AGENT:
|
||||
# TODO: reset based on user_agent?
|
||||
return reset(ip, username, ip_or_username)
|
||||
|
|
|
|||
|
|
@ -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,19 +23,19 @@ 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 | 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. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP | False | DEPRECATED: USE ``AXES_LOCKOUT_PARAMETERS`` INSTEAD. If ``True``, prevent login from IP under a particular username if the attempt limit has been exceeded, otherwise lock out based on IP. |
|
||||
| AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP | False | If ``True``, prevent login from IP under a particular username if the attempt limit has been exceeded, otherwise lock out based on IP. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_LOCK_OUT_BY_USER_OR_IP | False | DEPRECATED: USE ``AXES_LOCKOUT_PARAMETERS`` INSTEAD. If ``True``, prevent login from if the attempt limit has been exceeded for IP or username. |
|
||||
| AXES_LOCK_OUT_BY_USER_OR_IP | False | If ``True``, prevent login from if the attempt limit has been exceeded for IP or username. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_USE_USER_AGENT | False | DEPRECATED: USE ``AXES_LOCKOUT_PARAMETERS`` INSTEAD. If ``True``, lock out and log based on the IP address and the user agent. This means requests from different user agents but from the same IP are treated differently. This settings has no effect if the ``AXES_ONLY_USER_FAILURES`` setting is active. |
|
||||
| AXES_USE_USER_AGENT | False | If ``True``, lock out and log based on the IP address and the user agent. This means requests from different user agents but from the same IP are treated differently. This settings has no effect if the ``AXES_ONLY_USER_FAILURES`` setting is active. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_HANDLER | 'axes.handlers.database.AxesDatabaseHandler' | The path to the handler class to use. If set, overrides the default signal handler backend. Default: ``'axes.handlers.database.AxesDatabaseHandler'`` |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
|
|
@ -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. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
|
|
@ -83,8 +83,6 @@ The following ``settings.py`` options are available for customizing Axes behavio
|
|||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT | True | If ``True``, a failed login attempt during lockout will reset the cool off period. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_LOCKOUT_PARAMETERS | ["ip_address"] | A list of parameters that Axes uses to lock out users. It can also be callable, which takes an http request or AccesAttempt object and credentials and returns a list of parameters. Each parameter can be a string (a single parameter) or a list of strings (a combined parameter). For example, if you configure ``AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]``, axes will block clients by ip and/or username and user agent combination. See :ref:`customizing-lockout-parameters` for more details. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
|
||||
The configuration option precedences for the access attempt monitoring are:
|
||||
|
||||
|
|
@ -109,8 +107,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 +135,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
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ into ``my_namespace-username``:
|
|||
fine, but Axes does not inject these changes into the authentication flow
|
||||
for you.
|
||||
|
||||
|
||||
Customizing lockout responses
|
||||
-----------------------------
|
||||
|
||||
|
|
@ -166,57 +167,16 @@ 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``::
|
||||
|
||||
AXES_LOCKOUT_CALLABLE = "example.views.lockout"
|
||||
|
||||
.. _customizing-lockout-parameters:
|
||||
|
||||
Customizing lockout parameters
|
||||
------------------------------
|
||||
|
||||
Axes can be configured with ``AXES_LOCKOUT_PARAMETERS`` to lock out users not only by IP address.
|
||||
|
||||
``AXES_LOCKOUT_PARAMETERS`` can be a list of strings (which represents a separate lockout parameter) or nested lists of strings (which represents lockout parameters used in combination) or a callable which accepts HttpRequest or AccessAttempt and credentials and returns a list of the same form as described earlier.
|
||||
|
||||
Example ``AXES_LOCKOUT_PARAMETERS`` configuration:
|
||||
|
||||
``settings.py``::
|
||||
|
||||
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
|
||||
|
||||
Example of callable ``AXES_LOCKOUT_PARAMETERS``:
|
||||
|
||||
``example/utils.py``::
|
||||
|
||||
from django.http import HttpRequest
|
||||
|
||||
def get_lockout_parameters(request_or_attempt, credentials):
|
||||
|
||||
if isinstance(request_or_attempt, HttpRequest):
|
||||
is_localhost = request.META.get("REMOTE_ADDR") == "127.0.0.1"
|
||||
|
||||
else:
|
||||
is_localhost = request_or_attempt.ip_address == "127.0.0.1"
|
||||
|
||||
if is_localhost:
|
||||
return ["username"]
|
||||
|
||||
return ["ip_address", "username"]
|
||||
|
||||
``settings.py``::
|
||||
|
||||
AXES_LOCKOUT_PARAMETERS = "example.utils.get_lockout_parameters"
|
||||
|
||||
This way, if client ip_address is localhost, axes will lockout client only by username. In other case, axes will lockout client by username or ip_address.
|
||||
|
||||
Customizing client ip address lookups
|
||||
-------------------------------------
|
||||
-----------------------------
|
||||
|
||||
Axes can be configured with ``AXES_CLIENT_IP_CALLABLE`` to use custom client ip address lookup logic.
|
||||
|
||||
|
|
@ -227,4 +187,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.9.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
|
||||
|
|
|
|||
20
setup.py
20
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 :: 4.2",
|
||||
"Framework :: Django :: 5.2",
|
||||
"Framework :: Django :: 6.0",
|
||||
"Framework :: Django :: 3.2",
|
||||
"Framework :: Django :: 4.0",
|
||||
"Framework :: Django :: 4.1",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: System Administrators",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.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)
|
||||
|
||||
|
|
@ -83,74 +82,74 @@ class ResetResponseTestCase(AxesTestCase):
|
|||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 3)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
def test_reset_user_failures(self):
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 5)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
def test_reset_ip_user_failures(self):
|
||||
self.request.META["REMOTE_ADDR"] = self.IP_1
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 5)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
def test_reset_username_user_failures(self):
|
||||
self.request.GET["username"] = self.USERNAME_1
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 3)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
def test_reset_ip_username_user_failures(self):
|
||||
self.request.GET["username"] = self.USERNAME_1
|
||||
self.request.META["REMOTE_ADDR"] = self.IP_1
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 3)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
|
||||
def test_reset_user_or_ip(self):
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 5)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
|
||||
def test_reset_ip_user_or_ip(self):
|
||||
self.request.META["REMOTE_ADDR"] = self.IP_1
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 3)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
|
||||
def test_reset_username_user_or_ip(self):
|
||||
self.request.GET["username"] = self.USERNAME_1
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 3)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
|
||||
def test_reset_ip_username_user_or_ip(self):
|
||||
self.request.GET["username"] = self.USERNAME_1
|
||||
self.request.META["REMOTE_ADDR"] = self.IP_1
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 2)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
def test_reset_user_and_ip(self):
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 5)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
def test_reset_ip_user_and_ip(self):
|
||||
self.request.META["REMOTE_ADDR"] = self.IP_1
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 3)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
def test_reset_username_user_and_ip(self):
|
||||
self.request.GET["username"] = self.USERNAME_1
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 3)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_AND=True)
|
||||
def test_reset_ip_username_user_and_ip(self):
|
||||
self.request.GET["username"] = self.USERNAME_1
|
||||
self.request.META["REMOTE_ADDR"] = self.IP_1
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 4)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 3)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -280,10 +261,7 @@ class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase):
|
|||
_more = 10
|
||||
for i in range(settings.AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT + _more):
|
||||
self.create_failure_log()
|
||||
self.assertEqual(
|
||||
_more,
|
||||
AxesProxyHandler.remove_out_of_limit_failure_logs(username=self.username),
|
||||
)
|
||||
self.assertEqual(_more, AxesProxyHandler.remove_out_of_limit_failure_logs(username=self.username))
|
||||
|
||||
@override_settings(AXES_RESET_ON_SUCCESS=True)
|
||||
def test_handler(self):
|
||||
|
|
@ -318,7 +296,7 @@ class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase):
|
|||
def test_whitelist(self, log):
|
||||
self.check_whitelist(log)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@patch("axes.handlers.database.log")
|
||||
def test_user_login_failed_only_user_failures_with_none_username(self, log):
|
||||
credentials = {"username": None, "password": "test"}
|
||||
|
|
@ -327,7 +305,7 @@ class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase):
|
|||
attempt = AccessAttempt.objects.all()
|
||||
self.assertEqual(0, AccessAttempt.objects.count())
|
||||
log.warning.assert_called_with(
|
||||
"AXES: Username is None and username is the only one lockout parameter, new record will NOT be created."
|
||||
"AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, new record will NOT be created."
|
||||
)
|
||||
|
||||
def test_user_login_failed_with_none_username(self):
|
||||
|
|
@ -340,37 +318,22 @@ class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase):
|
|||
def test_user_login_failed_multiple_username(self):
|
||||
configurations = (
|
||||
(2, 1, {}, ["admin", "admin1"]),
|
||||
(2, 1, {"AXES_USE_USER_AGENT": True}, ["admin", "admin1"]),
|
||||
(2, 1, {"AXES_ONLY_USER_FAILURES": True}, ["admin", "admin1"]),
|
||||
(
|
||||
2,
|
||||
1,
|
||||
{"AXES_LOCKOUT_PARAMETERS": [["ip_address", "user_agent"]]},
|
||||
["admin", "admin1"],
|
||||
),
|
||||
(2, 1, {"AXES_LOCKOUT_PARAMETERS": ["username"]}, ["admin", "admin1"]),
|
||||
(
|
||||
2,
|
||||
1,
|
||||
{"AXES_LOCKOUT_PARAMETERS": [["username", "ip_address"]]},
|
||||
{"AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP": True},
|
||||
["admin", "admin1"],
|
||||
),
|
||||
(
|
||||
1,
|
||||
2,
|
||||
{"AXES_LOCKOUT_PARAMETERS": [["username", "ip_address"]]},
|
||||
{"AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP": True},
|
||||
["admin", "admin"],
|
||||
),
|
||||
(
|
||||
1,
|
||||
2,
|
||||
{"AXES_LOCKOUT_PARAMETERS": ["username", "ip_address"]},
|
||||
["admin", "admin"],
|
||||
),
|
||||
(
|
||||
2,
|
||||
1,
|
||||
{"AXES_LOCKOUT_PARAMETERS": ["username", "ip_address"]},
|
||||
["admin", "admin1"],
|
||||
),
|
||||
(1, 2, {"AXES_LOCK_OUT_BY_USER_OR_IP": True}, ["admin", "admin"]),
|
||||
(2, 1, {"AXES_LOCK_OUT_BY_USER_OR_IP": True}, ["admin", "admin1"]),
|
||||
)
|
||||
|
||||
for (
|
||||
|
|
@ -437,7 +400,7 @@ class ResetAttemptsCacheHandlerTestCase(AxesHandlerBaseTestCase):
|
|||
with self.assertRaises(NotImplementedError):
|
||||
AxesProxyHandler.reset_attempts()
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
def test_handler_reset_attempts_username(self):
|
||||
self.set_up_login_attempts()
|
||||
self.assertEqual(
|
||||
|
|
@ -473,7 +436,7 @@ class ResetAttemptsCacheHandlerTestCase(AxesHandlerBaseTestCase):
|
|||
self.check_failures(0, ip_address=self.IP_1)
|
||||
self.check_failures(2, ip_address=self.IP_2)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
def test_handler_reset_attempts_ip_and_username(self):
|
||||
self.set_up_login_attempts()
|
||||
self.check_failures(1, username=self.USERNAME_1, ip_address=self.IP_1)
|
||||
|
|
@ -519,7 +482,7 @@ class AxesCacheHandlerTestCase(AxesHandlerBaseTestCase):
|
|||
def test_whitelist(self, log):
|
||||
self.check_whitelist(log)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@patch.object(cache, "set")
|
||||
@patch("axes.handlers.cache.log")
|
||||
def test_user_login_failed_only_user_failures_with_none_username(
|
||||
|
|
@ -530,7 +493,7 @@ class AxesCacheHandlerTestCase(AxesHandlerBaseTestCase):
|
|||
AxesProxyHandler.user_login_failed(sender, credentials, self.request)
|
||||
self.assertFalse(cache_set.called)
|
||||
log.warning.assert_called_with(
|
||||
"AXES: Username is None and username is the only one lockout parameter, new record will NOT be created."
|
||||
"AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, new record will NOT be created."
|
||||
)
|
||||
|
||||
@patch.object(cache, "add")
|
||||
|
|
@ -569,170 +532,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):
|
||||
|
|
@ -183,7 +150,7 @@ class ClientStringTestCase(AxesTestCase):
|
|||
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_VERBOSE=True)
|
||||
def test_verbose_user_only_client_details(self):
|
||||
username = "test@example.com"
|
||||
|
|
@ -200,7 +167,7 @@ class ClientStringTestCase(AxesTestCase):
|
|||
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_VERBOSE=False)
|
||||
def test_non_verbose_user_only_client_details(self):
|
||||
username = "test@example.com"
|
||||
|
|
@ -215,7 +182,7 @@ class ClientStringTestCase(AxesTestCase):
|
|||
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
@override_settings(AXES_VERBOSE=True)
|
||||
def test_verbose_user_ip_combo_client_details(self):
|
||||
username = "test@example.com"
|
||||
|
|
@ -232,7 +199,7 @@ class ClientStringTestCase(AxesTestCase):
|
|||
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
@override_settings(AXES_VERBOSE=False)
|
||||
def test_non_verbose_user_ip_combo_client_details(self):
|
||||
username = "test@example.com"
|
||||
|
|
@ -247,7 +214,7 @@ class ClientStringTestCase(AxesTestCase):
|
|||
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "user_agent"]])
|
||||
@override_settings(AXES_USE_USER_AGENT=True)
|
||||
@override_settings(AXES_VERBOSE=True)
|
||||
def test_verbose_user_agent_client_details(self):
|
||||
username = "test@example.com"
|
||||
|
|
@ -264,7 +231,7 @@ class ClientStringTestCase(AxesTestCase):
|
|||
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "user_agent"]])
|
||||
@override_settings(AXES_USE_USER_AGENT=True)
|
||||
@override_settings(AXES_VERBOSE=False)
|
||||
def test_non_verbose_user_agent_client_details(self):
|
||||
username = "test@example.com"
|
||||
|
|
@ -333,79 +300,67 @@ def get_dummy_client_str_using_request(
|
|||
return f"{request.user.email}"
|
||||
|
||||
|
||||
def get_dummy_lockout_parameters(request, credentials=None):
|
||||
return ["ip_address", ["username", "user_agent"]]
|
||||
|
||||
|
||||
class ClientParametersTestCase(AxesTestCase):
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
def test_get_filter_kwargs_user(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent),
|
||||
[{"username": self.username}],
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
AXES_ONLY_USER_FAILURES=False,
|
||||
AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=False,
|
||||
AXES_USE_USER_AGENT=False,
|
||||
)
|
||||
def test_get_filter_kwargs_ip(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent),
|
||||
[{"ip_address": self.ip_address}],
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(
|
||||
AXES_ONLY_USER_FAILURES=False,
|
||||
AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True,
|
||||
AXES_USE_USER_AGENT=False,
|
||||
)
|
||||
def test_get_filter_kwargs_user_and_ip(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent),
|
||||
[{"username": self.username, "ip_address": self.ip_address}],
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "user_agent"]])
|
||||
def test_get_filter_kwargs_user_and_user_agent(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
|
||||
[{"username": self.username, "user_agent": self.user_agent}],
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["ip_address", ["username", "user_agent"]])
|
||||
def test_get_filter_kwargs_ip_or_user_and_user_agent(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
|
||||
[{"ip_address": self.ip_address}, {"username": self.username, "user_agent": self.user_agent}],
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "user_agent"], ["username", "user_agent"]])
|
||||
def test_get_filter_kwargs_ip_and_user_agent_or_user_and_user_agent(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
|
||||
[{"ip_address": self.ip_address, "user_agent": self.user_agent}, {"username": self.username, "user_agent": self.user_agent}],
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
@override_settings(
|
||||
AXES_ONLY_USER_FAILURES=False,
|
||||
AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=False,
|
||||
AXES_LOCK_OUT_BY_USER_OR_IP=True,
|
||||
AXES_USE_USER_AGENT=False,
|
||||
)
|
||||
def test_get_filter_kwargs_user_or_ip(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent),
|
||||
[{"username": self.username}, {"ip_address": self.ip_address}],
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address", "user_agent"])
|
||||
def test_get_filter_kwargs_user_or_ip_or_user_agent(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
|
||||
[{"username": self.username}, {"ip_address": self.ip_address}, {"user_agent": self.user_agent}],
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "user_agent"]])
|
||||
@override_settings(
|
||||
AXES_ONLY_USER_FAILURES=False,
|
||||
AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=False,
|
||||
AXES_USE_USER_AGENT=True,
|
||||
)
|
||||
def test_get_filter_kwargs_ip_and_agent(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent),
|
||||
[{"ip_address": self.ip_address, "user_agent": self.user_agent}],
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
AXES_LOCKOUT_PARAMETERS=[["username", "ip_address", "user_agent"]]
|
||||
AXES_ONLY_USER_FAILURES=False,
|
||||
AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True,
|
||||
AXES_USE_USER_AGENT=True,
|
||||
)
|
||||
def test_get_filter_kwargs_user_ip_agent(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent),
|
||||
[
|
||||
{
|
||||
"username": self.username,
|
||||
|
|
@ -415,180 +370,6 @@ class ClientParametersTestCase(AxesTestCase):
|
|||
],
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["wrong_param"])
|
||||
@patch("axes.helpers.log")
|
||||
def test_get_filter_kwargs_invalid_parameter(self, log):
|
||||
with self.assertRaises(ValueError):
|
||||
get_client_parameters(
|
||||
self.username,
|
||||
self.ip_address,
|
||||
self.user_agent,
|
||||
self.request,
|
||||
self.credentials,
|
||||
)
|
||||
log.exception.assert_called_with(
|
||||
(
|
||||
"wrong_param lockout parameter is not allowed. "
|
||||
"Allowed lockout parameters: username, ip_address, user_agent"
|
||||
)
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "wrong_param"]])
|
||||
@patch("axes.helpers.log")
|
||||
def test_get_filter_kwargs_invalid_combined_parameter(self, log):
|
||||
with self.assertRaises(ValueError):
|
||||
get_client_parameters(
|
||||
self.username,
|
||||
self.ip_address,
|
||||
self.user_agent,
|
||||
self.request,
|
||||
self.credentials,
|
||||
)
|
||||
log.exception.assert_called_with(
|
||||
(
|
||||
"wrong_param lockout parameter is not allowed. "
|
||||
"Allowed lockout parameters: username, ip_address, user_agent"
|
||||
)
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=get_dummy_lockout_parameters)
|
||||
def test_get_filter_kwargs_callable_lockout_parameters(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(
|
||||
self.username,
|
||||
self.ip_address,
|
||||
self.user_agent,
|
||||
self.request,
|
||||
self.credentials,
|
||||
),
|
||||
[
|
||||
{
|
||||
"ip_address": self.ip_address,
|
||||
},
|
||||
{
|
||||
"username": self.username,
|
||||
"user_agent": self.user_agent,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
AXES_LOCKOUT_PARAMETERS="tests.test_helpers.get_dummy_lockout_parameters"
|
||||
)
|
||||
def test_get_filter_kwargs_callable_str_lockout_parameters(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(
|
||||
self.username,
|
||||
self.ip_address,
|
||||
self.user_agent,
|
||||
self.request,
|
||||
self.credentials,
|
||||
),
|
||||
[
|
||||
{
|
||||
"ip_address": self.ip_address,
|
||||
},
|
||||
{
|
||||
"username": self.username,
|
||||
"user_agent": self.user_agent,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
AXES_LOCKOUT_PARAMETERS=lambda request, credentials: ["username"]
|
||||
)
|
||||
def test_get_filter_kwargs_callable_lambda_lockout_parameters(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(
|
||||
self.username,
|
||||
self.ip_address,
|
||||
self.user_agent,
|
||||
self.request,
|
||||
self.credentials,
|
||||
),
|
||||
[
|
||||
{
|
||||
"username": self.username,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=True)
|
||||
def test_get_filter_kwargs_not_list_or_callable(self):
|
||||
with self.assertRaises(TypeError):
|
||||
get_client_parameters(
|
||||
self.username,
|
||||
self.ip_address,
|
||||
self.user_agent,
|
||||
self.request,
|
||||
self.credentials,
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=lambda: None)
|
||||
def test_get_filter_kwargs_invalid_callable_too_few_arguments(self):
|
||||
with self.assertRaises(TypeError):
|
||||
get_client_parameters(
|
||||
self.username,
|
||||
self.ip_address,
|
||||
self.user_agent,
|
||||
self.request,
|
||||
self.credentials,
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=lambda request, credentials, extra: None)
|
||||
def test_get_filter_kwargs_invalid_callable_too_many_arguments(self):
|
||||
with self.assertRaises(TypeError):
|
||||
get_client_parameters(
|
||||
self.username,
|
||||
self.ip_address,
|
||||
self.user_agent,
|
||||
self.request,
|
||||
self.credentials,
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
AXES_LOCKOUT_PARAMETERS=lambda request, credentials: ["wrong_param"]
|
||||
)
|
||||
@patch("axes.helpers.log")
|
||||
def test_get_filter_kwargs_callable_invalid_lockout_param(self, log):
|
||||
with self.assertRaises(ValueError):
|
||||
get_client_parameters(
|
||||
self.username,
|
||||
self.ip_address,
|
||||
self.user_agent,
|
||||
self.request,
|
||||
self.credentials,
|
||||
)
|
||||
log.exception.assert_called_with(
|
||||
(
|
||||
"wrong_param lockout parameter is not allowed. "
|
||||
"Allowed lockout parameters: username, ip_address, user_agent"
|
||||
)
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
AXES_LOCKOUT_PARAMETERS=lambda request, credentials: [
|
||||
["ip_address", "wrong_param"]
|
||||
]
|
||||
)
|
||||
@patch("axes.helpers.log")
|
||||
def test_get_filter_kwargs_callable_invalid_combined_lockout_param(self, log):
|
||||
with self.assertRaises(ValueError):
|
||||
get_client_parameters(
|
||||
self.username,
|
||||
self.ip_address,
|
||||
self.user_agent,
|
||||
self.request,
|
||||
self.credentials,
|
||||
)
|
||||
log.exception.assert_called_with(
|
||||
(
|
||||
"wrong_param lockout parameter is not allowed. "
|
||||
"Allowed lockout parameters: username, ip_address, user_agent"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ClientCacheKeyTestCase(AxesTestCase):
|
||||
def test_get_cache_keys(self):
|
||||
|
|
@ -947,7 +728,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 +742,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 +794,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 +820,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 +834,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 +855,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)
|
||||
|
|
@ -33,46 +34,40 @@ class AppsTestCase(AxesTestCase):
|
|||
AppConfig.initialize()
|
||||
self.assertFalse(log.info.called)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
def test_axes_config_log_user_only(self, log):
|
||||
AppConfig.initialize()
|
||||
log.info.assert_called_with(_BEGIN, _VERSION, "blocking by username")
|
||||
log.info.assert_called_with(_BEGIN, _VERSION, "blocking by username only")
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=False)
|
||||
def test_axes_config_log_ip_only(self, log):
|
||||
AppConfig.initialize()
|
||||
log.info.assert_called_with(_BEGIN, _VERSION, "blocking by ip_address")
|
||||
log.info.assert_called_with(_BEGIN, _VERSION, "blocking by IP only")
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
def test_axes_config_log_user_ip(self, log):
|
||||
AppConfig.initialize()
|
||||
log.info.assert_called_with(
|
||||
_BEGIN, _VERSION, "blocking by combination of username and ip_address"
|
||||
)
|
||||
log.info.assert_called_with(_BEGIN, _VERSION, "blocking by combination of username and IP")
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
|
||||
def test_axes_config_log_user_or_ip(self, log):
|
||||
AppConfig.initialize()
|
||||
log.info.assert_called_with(_BEGIN, _VERSION, "blocking by username or ip_address")
|
||||
log.info.assert_called_with(_BEGIN, _VERSION, "blocking by username or IP")
|
||||
|
||||
|
||||
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 +76,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 +85,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 +108,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):
|
||||
|
|
@ -188,11 +182,10 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
|
||||
self.assertTrue(self.attempt_count())
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
def test_lockout_by_combination_user_and_ip(self):
|
||||
"""
|
||||
Test login failure when lockout parameters is combination
|
||||
of username and ip_address.
|
||||
Test login failure when AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP is True.
|
||||
"""
|
||||
|
||||
# test until one try before the limit
|
||||
|
|
@ -206,10 +199,10 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self.login(is_valid_username=True, is_valid_password=False)
|
||||
self.assertContains(response, self.LOCKED_MESSAGE, status_code=429)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
def test_lockout_by_only_user_failures(self):
|
||||
"""
|
||||
Test login failure when lockout parameter is username.
|
||||
Test login failure when AXES_ONLY_USER_FAILURES is True.
|
||||
"""
|
||||
|
||||
# test until one try before the limit
|
||||
|
|
@ -245,139 +238,6 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response, self.LOGIN_FORM_KEY, status_code=self.ALLOWED, html=True
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["user_agent"])
|
||||
def test_lockout_by_user_agent_only(self):
|
||||
"""
|
||||
Test login failure when lockout parameter is only user_agent
|
||||
"""
|
||||
# User is locked out with "test-browser" user agent.
|
||||
self._lockout_user_from_ip(username="username", ip_addr=self.IP_1, user_agent="test-browser")
|
||||
|
||||
# Test he is locked:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is locked with another username:
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is locked with another ip:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test with another user agent:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser-2")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["ip_address", "username", "user_agent"])
|
||||
def test_lockout_by_all_parameters(self):
|
||||
# User is locked out with "test-browser" user agent.
|
||||
self._lockout_user_from_ip(username="username", ip_addr=self.IP_1, user_agent="test-browser")
|
||||
|
||||
# Test he is locked:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is locked by username:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser2")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is locked by ip:
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser2")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# 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)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "username", "user_agent"]])
|
||||
def test_lockout_by_combination_of_all_parameters(self):
|
||||
# User is locked out with "test-browser" user agent.
|
||||
self._lockout_user_from_ip(username="username", ip_addr=self.IP_1, user_agent="test-browser")
|
||||
|
||||
# Test he is locked:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is allowed to login with different username:
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
||||
# Test he is allowed to login with different IP:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
||||
# 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)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["ip_address", ["username", "user_agent"]])
|
||||
def test_lockout_by_ip_or_username_and_user_agent(self):
|
||||
# User is locked out with "test-browser" user agent.
|
||||
self._lockout_user_from_ip(username="username", ip_addr=self.IP_1, user_agent="test-browser")
|
||||
|
||||
# Test he is locked:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is locked by ip:
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser2")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is locked by username and user_agent:
|
||||
response = self._login("username", 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 and ip
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
||||
# Test he is allowed to login with different user_agent and ip
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_2, 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)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "user_agent"], ["username", "user_agent"]])
|
||||
def test_lockout_by_ip_and_user_agent_or_username_and_user_agent(self):
|
||||
# User is locked out with "test-browser" user agent.
|
||||
self._lockout_user_from_ip(username="username", ip_addr=self.IP_1, user_agent="test-browser")
|
||||
|
||||
# Test he is locked:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is locked by ip and user_agent:
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is locked by username and user_agent:
|
||||
response = self._login("username", 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 and ip
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
# Test for true and false positives when blocking by IP *OR* user (default)
|
||||
# Cache disabled. Default settings.
|
||||
def test_lockout_by_ip_blocks_when_same_user_same_ip_without_cache(self):
|
||||
|
|
@ -414,7 +274,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
|
||||
# Test for true and false positives when blocking by user only.
|
||||
# Cache disabled. When AXES_ONLY_USER_FAILURES = True
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
def test_lockout_by_user_blocks_when_same_user_same_ip_without_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -423,7 +283,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
def test_lockout_by_user_blocks_when_same_user_diff_ip_without_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -432,7 +292,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
def test_lockout_by_user_allows_when_diff_user_same_ip_without_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -441,7 +301,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
def test_lockout_by_user_allows_when_diff_user_diff_ip_without_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -450,7 +310,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
def test_lockout_by_user_with_empty_username_allows_other_users_without_cache(self):
|
||||
# User with empty username is locked out from IP 1.
|
||||
self._lockout_user_from_ip(username="", ip_addr=self.IP_1)
|
||||
|
|
@ -461,7 +321,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
|
||||
# Test for true and false positives when blocking by user and IP together.
|
||||
# Cache disabled. When LOCK_OUT_BY_COMBINATION_USER_AND_IP = True
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
def test_lockout_by_user_and_ip_blocks_when_same_user_same_ip_without_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -470,7 +330,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
def test_lockout_by_user_and_ip_allows_when_same_user_diff_ip_without_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -479,7 +339,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
def test_lockout_by_user_and_ip_allows_when_diff_user_same_ip_without_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -488,7 +348,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
def test_lockout_by_user_and_ip_allows_when_diff_user_diff_ip_without_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -497,7 +357,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
def test_lockout_by_user_and_ip_with_empty_username_allows_other_users_without_cache(
|
||||
self,
|
||||
):
|
||||
|
|
@ -508,7 +368,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self.client.get(reverse("admin:login"), REMOTE_ADDR=self.IP_1)
|
||||
self.assertContains(response, self.LOGIN_FORM_KEY, status_code=200, html=True)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "user_agent"]])
|
||||
@override_settings(AXES_USE_USER_AGENT=True)
|
||||
def test_lockout_by_user_still_allows_login_with_differnet_user_agent(self):
|
||||
# User with empty username is locked out with "test-browser" user agent.
|
||||
self._lockout_user_from_ip(username="username", ip_addr=self.IP_1, user_agent="test-browser")
|
||||
|
|
@ -555,7 +415,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
def test_lockout_by_user_with_empty_username_allows_other_users_using_cache(self):
|
||||
# User with empty username is locked out from IP 1.
|
||||
self._lockout_user_from_ip(username="", ip_addr=self.IP_1)
|
||||
|
|
@ -566,7 +426,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
|
||||
# Test for true and false positives when blocking by user only.
|
||||
# With cache enabled. When AXES_ONLY_USER_FAILURES = True
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
def test_lockout_by_user_blocks_when_same_user_same_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -575,7 +435,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
def test_lockout_by_user_blocks_when_same_user_diff_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -584,7 +444,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
def test_lockout_by_user_allows_when_diff_user_same_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -593,7 +453,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
def test_lockout_by_user_allows_when_diff_user_diff_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -604,7 +464,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
|
||||
# Test for true and false positives when blocking by user and IP together.
|
||||
# With cache enabled. When LOCK_OUT_BY_COMBINATION_USER_AND_IP = True
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
def test_lockout_by_user_and_ip_blocks_when_same_user_same_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -613,7 +473,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
def test_lockout_by_user_and_ip_allows_when_same_user_diff_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -622,7 +482,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
def test_lockout_by_user_and_ip_allows_when_diff_user_same_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -631,7 +491,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
def test_lockout_by_user_and_ip_allows_when_diff_user_diff_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -641,7 +501,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(
|
||||
AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]], AXES_FAILURE_LIMIT=2
|
||||
AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True, AXES_FAILURE_LIMIT=2
|
||||
)
|
||||
def test_lockout_by_user_and_ip_allows_when_diff_user_same_ip_using_cache_multiple_attempts(
|
||||
self,
|
||||
|
|
@ -670,7 +530,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
def test_lockout_by_user_and_ip_with_empty_username_allows_other_users_using_cache(
|
||||
self,
|
||||
):
|
||||
|
|
@ -683,7 +543,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
|
||||
# Test for true and false positives when blocking by user or IP together.
|
||||
# With cache enabled. When AXES_LOCK_OUT_BY_USER_OR_IP = True
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
|
||||
def test_lockout_by_user_or_ip_blocks_when_same_user_same_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -692,7 +552,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
|
||||
def test_lockout_by_user_or_ip_allows_when_same_user_diff_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -701,7 +561,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
|
||||
def test_lockout_by_user_or_ip_allows_when_diff_user_same_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -710,9 +570,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
@override_settings(
|
||||
AXES_LOCKOUT_PARAMETERS=["username", "ip_address"], AXES_FAILURE_LIMIT=3
|
||||
)
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True, AXES_FAILURE_LIMIT=3)
|
||||
def test_lockout_by_user_or_ip_allows_when_diff_user_same_ip_using_cache_multiple_attempts(
|
||||
self,
|
||||
):
|
||||
|
|
@ -742,9 +600,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_3, self.WRONG_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
|
||||
|
||||
@override_settings(
|
||||
AXES_LOCKOUT_PARAMETERS=["username", "ip_address"], AXES_FAILURE_LIMIT=3
|
||||
)
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True, AXES_FAILURE_LIMIT=3)
|
||||
def test_lockout_by_user_or_ip_allows_when_diff_user_same_ip_using_cache_multiple_failed_attempts(
|
||||
self,
|
||||
):
|
||||
|
|
@ -769,7 +625,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
|
||||
def test_lockout_by_user_or_ip_allows_when_diff_user_diff_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -778,7 +634,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
|
||||
def test_lockout_by_user_or_ip_with_empty_username_allows_other_users_using_cache(
|
||||
self,
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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